Files
wehub-resource-sync bf2343b7e4
Integration Tests - MySQL + Elasticsearch / Detect Changes (push) Has been cancelled
Integration Tests - MySQL + Elasticsearch / integration-tests-mysql-elasticsearch (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / integration-tests-postgres-elasticsearch-redis (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / integration-tests-postgres-opensearch (push) Has been cancelled
Java Checkstyle / java-checkstyle (push) Has been cancelled
Maven Collate Tests / maven-collate-ci (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Has been cancelled
Publish Package to Maven Central Repository / publish-maven-packages (push) Has been cancelled
OpenMetadata Service Unit Tests / Detect Changes (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (push) Has been cancelled
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:35:45 +08:00

1391 lines
47 KiB
Markdown

# Adding Auto-Classification Support for New Entity Types
This guide documents the standardized process for adding auto-classification (PII detection) support to new entity types in OpenMetadata, based on the pattern established when adding Container entity support in [PR #26495](https://github.com/open-metadata/OpenMetadata/pull/26495).
## Overview
Auto-classification extends OpenMetadata's ability to automatically detect and tag sensitive data (PII) in different entity types. Originally built for Table entities, the system uses a schema-first, type-safe approach with parallel implementations across:
- JSON Schema specifications
- Java backend (REST API, persistence, authorization)
- Python ingestion framework (sampling, classification, data fetching)
- TypeScript frontend (UI configuration)
## Prerequisites
Before adding support for a new entity type (e.g., Topic, Dashboard, SearchIndex):
1. The entity must have a column-like structure (fields that can be classified)
2. The entity schema must support storing sample data
3. You must be able to sample/read data from the underlying source system
## Step-by-Step Implementation
### 1. Schema Changes (JSON Schema)
#### 1.1 Update Entity Schema to Support Sample Data
**Location:** `openmetadata-spec/src/main/resources/json/schema/entity/data/<entity>.json`
**Example (Container):**
```json
{
"sampleData": {
"description": "Sample data for the container.",
"$ref": "../data/table.json#/definitions/tableData",
"default": null
}
}
```
**Action:** Add a `sampleData` field to your entity schema that references the standard `tableData` definition.
#### 1.2 Create Service-Specific Auto-Classification Pipeline Schema
**Location:** `openmetadata-spec/src/main/resources/json/schema/metadataIngestion/<serviceType>ServiceAutoClassificationPipeline.json`
**Example:** `storageServiceAutoClassificationPipeline.json`
```json
{
"$id": "https://open-metadata.org/schema/metadataIngestion/storageServiceAutoClassificationPipeline.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "StorageServiceAutoClassificationPipeline",
"description": "StorageService AutoClassification Pipeline Configuration.",
"type": "object",
"definitions": {
"autoClassificationConfigType": {
"description": "Storage Service Auto Classification Pipeline type",
"type": "string",
"enum": ["AutoClassification"],
"default": "AutoClassification"
}
},
"properties": {
"type": {
"description": "Pipeline type",
"$ref": "#/definitions/autoClassificationConfigType",
"default": "AutoClassification"
},
"classificationFilterPattern": {
"description": "Regex to only compute metrics for entities that match the pattern",
"$ref": "../type/filterPattern.json#/definitions/filterPattern"
},
"entityFilterPattern": {
"description": "Entity-specific filter patterns (e.g., bucketFilterPattern, topicFilterPattern)",
"$ref": "../type/filterPattern.json#/definitions/filterPattern"
},
"useFqnForFiltering": {
"type": "boolean",
"default": false
},
"storeSampleData": {
"description": "Option to turn on/off storing sample data. If enabled, we will ingest sample data for each entity.",
"type": "boolean",
"default": false,
"title": "Store Sample Data"
},
"enableAutoClassification": {
"type": "boolean",
"default": true
},
"confidence": {
"type": "number",
"default": 80
},
"sampleDataCount": {
"type": "integer",
"default": 50
},
"classificationLanguage": {
"$ref": "../type/classificationLanguages.json",
"default": "en"
}
}
}
```
**Key patterns:**
- Include entity-specific filter patterns (e.g., `bucketFilterPattern` for storage, `topicFilterPattern` for messaging)
- Keep consistent property names: `storeSampleData`, `enableAutoClassification`, `confidence`, `sampleDataCount`
- Reference standard filter patterns and classification languages
- **Important:** `storeSampleData` defaults to `false` to avoid storing large datasets by default. Users must explicitly enable it.
#### 1.3 Register Pipeline in Workflow Schema
**Location:** `openmetadata-spec/src/main/resources/json/schema/metadataIngestion/workflow.json`
**Change:**
```json
{
"sourceConfig": {
"config": {
"oneOf": [
{ "$ref": "databaseServiceAutoClassificationPipeline.json" },
{ "$ref": "storageServiceAutoClassificationPipeline.json" },
{ "$ref": "messagingServiceAutoClassificationPipeline.json" } // Your new schema
]
}
}
}
```
#### 1.4 Add `supportsProfiler` to Connection Schemas
**Location:** `openmetadata-spec/src/main/resources/json/schema/entity/services/connections/<serviceType>/<connector>Connection.json`
**Example:** All storage connection schemas (S3, GCS, ADLS, Custom Storage)
```json
{
"properties": {
"supportsProfiler": {
"title": "Supports Profiler",
"$ref": "../connectionBasicType.json#/definitions/supportsProfiler"
}
}
}
```
**Action:** Add this field to **all** connector connection schemas for your service type.
#### 1.5 Rebuild Schemas
After making schema changes:
```bash
cd openmetadata-spec
mvn clean install
```
This regenerates Java and TypeScript models.
---
### 2. Backend Changes (Java)
#### 2.1 Extend Entity Repository
**Location:** `openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/<Entity>Repository.java`
**Required methods:**
```java
public static final String ENTITY_SAMPLE_DATA_EXTENSION = "entity.sampleData";
public Entity addSampleData(UUID entityId, TableData tableData) {
Entity entity = find(entityId, NON_DELETED);
// Validate columns exist in the entity
if (entity.getColumns() != null) {
for (String columnName : tableData.getColumns()) {
validateColumn(entity.getColumns(), columnName);
}
}
// Validate row structure
for (List<Object> row : tableData.getRows()) {
if (row.size() != tableData.getColumns().size()) {
throw new IllegalArgumentException(
String.format(
"Number of columns is %d but row has %d sample values",
tableData.getColumns().size(), row.size()));
}
}
// Store in entity_extension table
daoCollection
.entityExtensionDAO()
.insert(
entityId,
ENTITY_SAMPLE_DATA_EXTENSION,
"tableData",
JsonUtils.pojoToJson(tableData));
setFieldsInternal(entity, Fields.EMPTY_FIELDS);
return entity.withSampleData(tableData);
}
public Entity getSampleData(UUID entityId, boolean authorizePII) {
Entity entity = find(entityId, NON_DELETED);
TableData sampleData = JsonUtils.readValue(
daoCollection
.entityExtensionDAO()
.getExtension(entity.getId(), ENTITY_SAMPLE_DATA_EXTENSION),
TableData.class);
entity.setSampleData(sampleData);
setFieldsInternal(entity, Fields.EMPTY_FIELDS);
// Apply PII masking if user doesn't have authorization
if (!authorizePII && entity.getColumns() != null) {
populateEntityFieldTags(
entityType,
entity.getColumns(),
entity.getFullyQualifiedName(),
true);
entity.setTags(getTags(entity));
return PIIMasker.getSampleData(entity);
}
return entity;
}
@Transaction
public Entity deleteSampleData(UUID entityId) {
Entity entity = find(entityId, NON_DELETED);
daoCollection.entityExtensionDAO().delete(entityId, ENTITY_SAMPLE_DATA_EXTENSION);
setFieldsInternal(entity, Fields.EMPTY_FIELDS);
return entity;
}
```
**Key points:**
- Sample data stored as extension (not in main entity table)
- Column validation ensures data integrity
- PII masking applied during retrieval based on authorization
#### 2.2 Update EntityRepository Base Class (if needed)
**Location:** `openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java`
If `validateColumn` is entity-specific, refactor to accept `List<Column>`:
```java
public static void validateColumn(List<Column> columns, String columnName) {
validateColumn(columns, columnName, Boolean.TRUE);
}
public static void validateColumn(
List<Column> columns, String columnName, Boolean caseSensitive) {
if (columns == null) {
throw new IllegalArgumentException("Columns list cannot be null");
}
// ... validation logic
}
```
#### 2.3 Add REST API Endpoints
**Location:** `openmetadata-service/src/main/java/org/openmetadata/service/resources/<domain>/<Entity>Resource.java`
**Required endpoints:**
```java
@PUT
@Path("/{id}/sampleData")
@Operation(operationId = "addSampleData", summary = "Add sample data")
public Entity addSampleData(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@PathParam("id") UUID id,
@Valid TableData tableData) {
OperationContext operationContext =
new OperationContext(entityType, MetadataOperation.EDIT_SAMPLE_DATA);
authorizer.authorize(securityContext, operationContext, getResourceContextById(id));
Entity entity = repository.addSampleData(id, tableData);
return addHref(uriInfo, entity);
}
@GET
@Path("/{id}/sampleData")
@Operation(operationId = "getSampleData", summary = "Get sample data")
public Entity getSampleData(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@PathParam("id") UUID id) {
OperationContext operationContext =
new OperationContext(entityType, MetadataOperation.VIEW_SAMPLE_DATA);
ResourceContext<?> resourceContext = getResourceContextById(id);
authorizer.authorize(securityContext, operationContext, resourceContext);
boolean authorizePII = authorizer.authorizePII(securityContext, resourceContext.getOwners());
Entity entity = repository.getSampleData(id, authorizePII);
return addHref(uriInfo, entity);
}
@DELETE
@Path("/{id}/sampleData")
@Operation(operationId = "deleteSampleData", summary = "Delete sample data")
public Entity deleteSampleData(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@PathParam("id") UUID id) {
OperationContext operationContext =
new OperationContext(entityType, MetadataOperation.EDIT_SAMPLE_DATA);
authorizer.authorize(securityContext, operationContext, getResourceContextById(id));
Entity entity = repository.deleteSampleData(id);
return addHref(uriInfo, entity);
}
```
**Update resource fields:**
```java
public static final String FIELDS =
"...,sampleData"; // Add sampleData to fields list
@Override
public List<MetadataOperation> getOperations() {
addViewOperation("sampleData", MetadataOperation.VIEW_SAMPLE_DATA);
return listOf(MetadataOperation.VIEW_SAMPLE_DATA, MetadataOperation.EDIT_SAMPLE_DATA);
}
```
#### 2.4 Extend PII Masker
**Location:** `openmetadata-service/src/main/java/org/openmetadata/service/security/mask/PIIMasker.java`
**Add entity-specific masking:**
```java
public static Entity getSampleData(Entity entity) {
if (entity.getColumns() != null) {
TableData sampleData = maskSampleData(
entity.getSampleData(),
entity,
entity.getColumns()
);
entity.setSampleData(sampleData);
}
return entity;
}
private static boolean hasPiiSensitiveTag(Entity entity) {
return entity.getTags().stream()
.map(TagLabel::getTagFQN)
.anyMatch(SENSITIVE_PII_TAG::equals);
}
```
**Update `maskSampleData` method:**
```java
public static TableData maskSampleData(
TableData sampleData, Object entity, List<Column> columns) {
if (sampleData == null) {
return null;
}
// Check if entity itself is marked as PII
boolean entityHasPiiTag = false;
if (entity instanceof Table) {
entityHasPiiTag = hasPiiSensitiveTag((Table) entity);
} else if (entity instanceof Container) {
entityHasPiiTag = hasPiiSensitiveTag((Container) entity);
} else if (entity instanceof Topic) { // Your new entity
entityHasPiiTag = hasPiiSensitiveTag((Topic) entity);
}
// ... rest of masking logic
}
```
#### 2.5 Update AutoClassificationBotPolicy
**Location:** `openmetadata-service/src/main/resources/json/data/policy/AutoClassificationBotPolicy.json`
**Add rule for new entity:**
```json
{
"rules": [
{
"name": "AutoClassificationBotRule-Allow-Entity",
"description": "Allow adding tags and sample data to entities",
"resources": ["YourEntityType"],
"operations": ["EditAll", "ViewAll"],
"effect": "allow"
}
]
}
```
#### 2.6 Create Database Migration
**Location:** `bootstrap/sql/migrations/native/<version>/mysql/postDataMigrationSQLScript.sql`
**Update bot policy in database:**
```sql
UPDATE policy_entity
SET json = JSON_INSERT(
json,
'$.rules[2]',
JSON_OBJECT(
'name', 'AutoClassificationBotRule-Allow-YourEntity',
'description', 'Allow adding tags and sample data to your entities',
'resources', JSON_ARRAY('YourEntityType'),
'operations', JSON_ARRAY('EditAll', 'ViewAll'),
'effect', 'allow'
)
)
WHERE name = 'AutoClassificationBotPolicy';
```
**Repeat for PostgreSQL:** `bootstrap/sql/migrations/native/<version>/postgres/postDataMigrationSQLScript.sql`
---
### 3. Python Ingestion Changes
#### 3.1 Extend ClassifiableEntityType Union
**Location:** `ingestion/src/metadata/pii/types.py`
```python
from typing import Union
from metadata.generated.schema.entity.data.container import Container
from metadata.generated.schema.entity.data.table import Table
from metadata.generated.schema.entity.data.topic import Topic # Your new entity
ClassifiableEntityType = Union[Table, Container, Topic]
```
#### 3.2 Register Entity Adapter
**Location:** `ingestion/src/metadata/sampler/entity_adapters.py`
This is the single source of truth for all per-entity-type knowledge. Adding a new entity means adding one adapter class decorated with `@register_adapter` — no manual dict wiring and no other ingestion files need to change.
```python
from typing import ClassVar
from metadata.generated.schema.entity.data.your_entity import YourEntity
from metadata.generated.schema.metadataIngestion.yourServiceAutoClassificationPipeline import (
YourServiceAutoClassificationPipeline,
)
@register_adapter(entity=YourEntity, pipeline=YourServiceAutoClassificationPipeline)
class YourEntityAdapter(EntityAdapter):
pipeline_config_class = YourServiceAutoClassificationPipeline
service_type = ServiceType.YourServiceType
patch_fields: ClassVar[list[str]] = ["tags", "<field-that-holds-columns>"]
def get_columns(self, entity: YourEntity) -> list[Column] | None:
# Return the list of columns/fields on the entity, or None if unavailable
return entity.your_column_field
def set_columns(self, entity: YourEntity, columns) -> None:
entity.your_column_field = columns
def build_sampler_kwargs(
self,
config: OpenMetadataWorkflowConfig,
metadata: OpenMetadata,
entity: YourEntity,
profiler_config,
source_config,
) -> dict | None:
return {
"service_connection_config": deepcopy(config.source.serviceConnection.root.config),
"ometa_client": metadata,
"entity": entity,
"config": SamplerConfig(
sample_data_count=source_config.sampleDataCount,
),
}
```
The `@register_adapter` decorator instantiates the adapter once and wires it into both the entity-type and pipeline-config lookup tables automatically.
**What this buys you:** `sampler/processor.py`, `pii/base_processor.py`, `ometa/mixins/patch_mixin.py`, and `ingestion/sink/metadata_rest.py` (column tag path) all pick up the new entity automatically — zero changes required in those files. The only sink change needed is registering a `_ingest_entity_sample_data` handler (step 3.7).
#### 3.3 Create Fetcher Strategy
**Location:** `ingestion/src/metadata/profiler/source/fetcher/fetcher_strategy.py`
**Add new strategy class:**
```python
class YourEntityFetcherStrategy(FetcherStrategy):
"""Fetcher strategy for YourEntity entities"""
def __init__(
self,
config: OpenMetadataWorkflowConfig,
metadata: OpenMetadata,
global_profiler_config: Optional[Settings],
status: Status,
) -> None:
super().__init__(config, metadata, global_profiler_config, status)
def _filter_entities(self, entities: Iterable[YourEntity]) -> Iterable[YourEntity]:
"""Filter entities based on configured patterns"""
entity_filter_pattern = getattr(
self.source_config, "entityFilterPattern", None
)
entities = [
entity
for entity in entities
if (
not entity_filter_pattern
or not self._filter_by_pattern(entity)
)
and (
not self.source_config.classificationFilterPattern
or not self.filter_classifications(entity)
)
and entity.columns is not None # Only entities with columns
]
return entities
def _get_entity_entities(self) -> Iterable[YourEntity]:
"""Get all entities from the service"""
entities = self.metadata.list_all_entities(
entity=YourEntity,
fields=["columns", "tags"], # Entity-specific fields
params={
"service": self.config.source.serviceName,
},
)
return self._filter_entities(entities)
def fetch(self) -> Iterator[Either[ProfilerSourceAndEntity]]:
"""Fetch entities from service"""
try:
profiler_source = profiler_source_factory.create(
self.config.source.type.lower(),
self.config,
None,
self.metadata,
self.global_profiler_config,
)
for entity in self._get_entity_entities():
yield Either(
left=None,
right=ProfilerSourceAndEntity(
profiler_source=profiler_source,
entity=entity,
),
)
except Exception as exc:
yield Either(
left=StackTraceError(
name=self.config.source.serviceName,
error=f"Error listing entities: {exc}",
stackTrace=traceback.format_exc(),
),
right=None,
)
```
#### 3.4 Create Sampler Implementation
**Location:** `ingestion/src/metadata/sampler/<serviceType>/<connector>/sampler.py`
**Example structure (based on S3Sampler):**
```python
from metadata.sampler.<serviceType>.sampler import <ServiceType>Sampler
class YourConnectorSampler(<ServiceType>Sampler):
"""Sampler for YourConnector entities"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize connector-specific clients
def _read_sample_data_from_source(self, entity: YourEntity) -> pd.DataFrame:
"""Read sample data from the actual source system
Returns:
pd.DataFrame: Sample data with columns matching entity schema
"""
# Connector-specific logic to:
# 1. Connect to source system
# 2. Read sample rows (up to self.sample_limit)
# 3. Return as pandas DataFrame
pass
```
**Base sampler (if needed):**
**Location:** `ingestion/src/metadata/sampler/<serviceType>/sampler.py`
```python
from abc import abstractmethod
from metadata.generated.schema.entity.data.yourEntity import YourEntity
from metadata.sampler.sampler_interface import SamplerInterface
class YourServiceSampler(SamplerInterface):
"""Base sampler for YourService entities"""
@abstractmethod
def _read_sample_data_from_source(self, entity: YourEntity) -> pd.DataFrame:
"""Read sample data from source - implemented by connectors"""
pass
def generate_sample_data(self) -> TableData:
"""Generate sample data using connector implementation"""
if not isinstance(self.entity, YourEntity):
raise ValueError(f"Expected YourEntity, got {type(self.entity)}")
df = self._read_sample_data_from_source(self.entity)
return TableData(
columns=list(df.columns),
rows=df.values.tolist()
)
```
**No `create()` override needed:** `SamplerInterface.create()` is now a pure constructor that simply forwards its arguments to `__init__()`. Non-database samplers inherit it as-is — no override required.
#### 3.5 Update Sampler Processor — No Changes Required
`ingestion/src/metadata/sampler/processor.py` does **not** need to change. It resolves the service type and dispatches via the adapter registry:
```python
# __init__ — picks up the new pipeline config class automatically:
_adapter = adapter_for_pipeline(self.source_config) # finds TopicAdapter
self.service_type = _adapter.service_type # ServiceType.Messaging
# _run — picks up the new entity class automatically:
adapter = adapter_for(entity) # finds TopicAdapter
sampler_kwargs = adapter.build_sampler_kwargs(...) # returns pre-resolved sampling values
```
Config resolution (partition_details, sample_query, include/exclude columns, sample_config, sample_data_count) now happens inside `build_sampler_kwargs()`, so `SamplerInterface.create()` receives already-resolved values ready to initialize the sampler.
The only file to change is `entity_adapters.py` (step 3.2).
#### 3.6 Add OpenMetadata API Mixin
The mixin covers **sample data ingestion only** — column tag patching is fully adapter-driven and requires no mixin changes.
**Location:** `ingestion/src/metadata/ingestion/ometa/mixins/<entity>_mixin.py`
```python
from metadata.generated.schema.entity.data.yourEntity import YourEntity
from metadata.generated.schema.type.table import TableData
from metadata.ingestion.ometa.client import REST
from metadata.utils.logger import ometa_logger
logger = ometa_logger()
class OMetaYourEntityMixin:
"""Mixin for YourEntity sample data API operations"""
client: REST
def ingest_your_entity_sample_data(
self,
entity: YourEntity,
sample_data: TableData,
) -> YourEntity:
try:
resp = self.client.put(
f"{self.get_suffix(YourEntity)}/{entity.id}/sampleData",
data=sample_data.model_dump_json(),
)
return YourEntity(**resp)
except Exception as exc:
logger.debug(traceback.format_exc())
logger.warning(
"Failed to ingest sample data for [%s]: %s",
entity.fullyQualifiedName.root,
exc,
)
return entity
```
**Register mixin in OMetaAPI:**
**Location:** `ingestion/src/metadata/ingestion/ometa/ometa_api.py`
```python
from metadata.ingestion.ometa.mixins.your_entity_mixin import OMetaYourEntityMixin
class OpenMetadata(
...,
OMetaYourEntityMixin,
):
pass
```
#### 3.7 Update Metadata Sink
**Location:** `ingestion/src/metadata/ingestion/sink/metadata_rest.py`
Column tag patching (`patch_column_tags`) is fully adapter-driven — `write_sampler_response` calls it directly for any entity type and no changes are needed there.
For **sample data storage**, the sink uses a `@singledispatchmethod`. Add one `@register` for your entity type:
```python
@_ingest_entity_sample_data.register
def _(self, entity: YourEntity, sample_data: TableData) -> bool:
result = self.metadata.ingest_your_entity_sample_data(
entity=entity, sample_data=sample_data
)
if result:
logger.debug(
"Successfully ingested sample data for %s",
entity.fullyQualifiedName.root,
)
return True
return False
```
`write_sampler_response` itself needs no changes — it calls `_ingest_entity_sample_data` and `patch_column_tags` generically for all entity types.
#### 3.8 Register Sampler in Service Spec
**Location:** `ingestion/src/metadata/ingestion/source/<serviceType>/<connector>/service_spec.py`
```python
from metadata.ingestion.source.<serviceType>.<connector>.metadata import YourSource
from metadata.sampler.<serviceType>.<connector>.sampler import YourSampler
from metadata.utils.service_spec import BaseSpec
ServiceSpec = BaseSpec(
metadata_source_class=YourSource,
sampler_class=YourSampler
)
```
---
### 4. Frontend Changes (TypeScript/React)
#### 4.1 Add Sample Data API Methods
**Location:** `openmetadata-ui/src/main/resources/ui/src/rest/<entity>API.ts`
Add API methods for sample data operations:
```typescript
export const getSampleDataByEntityId = async (id: string) => {
const response = await APIClient.get<YourEntity>(`${BASE_URL}/${id}/sampleData`);
return response.data;
};
export const deleteSampleDataByEntityId = async (id: string) => {
return await APIClient.delete<YourEntity>(`${BASE_URL}/${id}/sampleData`);
};
```
**Example (Container):**
```typescript
// openmetadata-ui/src/main/resources/ui/src/rest/storageAPI.ts
export const getSampleDataByContainerId = async (id: string) => {
const response = await APIClient.get<Container>(`${BASE_URL}/${id}/sampleData`);
return response.data;
};
export const deleteSampleDataByContainerId = async (id: string) => {
return await APIClient.delete<Container>(`${BASE_URL}/${id}/sampleData`);
};
```
#### 4.2 Update SampleDataTable Component to Support Multiple Entity Types
**Location:** `openmetadata-ui/src/main/resources/ui/src/components/Database/SampleDataTable/SampleData.interface.ts`
Add `entityType` parameter to the props interface:
```typescript
export interface SampleDataProps {
isTableDeleted?: boolean;
tableId: string;
owners: EntityReference[];
permissions: OperationPermission;
entityType?: EntityType.TABLE | EntityType.YOUR_ENTITY;
}
```
**Location:** `openmetadata-ui/src/main/resources/ui/src/components/Database/SampleDataTable/SampleDataTable.component.tsx`
Update the component to handle multiple entity types:
```typescript
import { EntityType } from '../../../enums/entity.enum';
import { YourEntity } from '../../../generated/entity/data/yourEntity';
import { Table } from '../../../generated/entity/data/table';
import {
deleteSampleDataByEntityId,
getSampleDataByEntityId,
} from '../../../rest/yourEntityAPI';
import {
deleteSampleDataByTableId,
getSampleDataByTableId,
} from '../../../rest/tableAPI';
const SampleDataTable: FC<SampleDataProps> = ({
isTableDeleted,
tableId,
owners,
permissions,
entityType = EntityType.TABLE,
}) => {
// Update getSampleDataWithType to handle multiple entity types
const getSampleDataWithType = (entity: Table | YourEntity) => {
const { sampleData } = entity;
// Get columns based on entity type
const columns =
'columns' in entity
? entity.columns // Table
: entity.yourFieldWithColumns?.columns ?? []; // YourEntity
// ... rest of the logic remains the same
};
// Update fetchSampleData to use correct API based on entity type
const fetchSampleData = async () => {
try {
const entityData =
entityType === EntityType.YOUR_ENTITY
? await getSampleDataByEntityId(tableId)
: await getSampleDataByTableId(tableId);
setSampleData(getSampleDataWithType(entityData));
} catch (error) {
showErrorToast(error as AxiosError);
} finally {
setIsLoading(false);
}
};
// Update handleDeleteSampleData similarly
const handleDeleteSampleData = async () => {
try {
if (entityType === EntityType.YOUR_ENTITY) {
await deleteSampleDataByEntityId(tableId);
} else {
await deleteSampleDataByTableId(tableId);
}
handleDeleteModal();
fetchSampleData();
} catch (error) {
showErrorToast(error as AxiosError);
}
};
};
```
#### 4.3 Add SAMPLE_DATA Tab to Entity Detail Page
**Location:** `openmetadata-ui/src/main/resources/ui/src/utils/<Entity>DetailsClassBase.ts`
Add `viewSampleDataPermission` and `entityPermissions` to the interface:
```typescript
export interface EntityDetailPageTabProps {
// ... existing props
viewSampleDataPermission: boolean;
entityPermissions: OperationPermission;
entityData?: YourEntity;
// ... rest of props
}
```
Add SAMPLE_DATA to the tab IDs list:
```typescript
public getEntityDetailPageTabsIds(): Tab[] {
return [
EntityTabs.SCHEMA,
EntityTabs.SAMPLE_DATA, // Add this
EntityTabs.ACTIVITY_FEED,
// ... other tabs
].map((tab: EntityTabs) => ({
id: tab,
name: tab,
displayName: getTabLabelFromId(tab),
layout: this.getDefaultLayout(tab),
editable: tab === EntityTabs.SCHEMA,
}));
}
```
**Location:** `openmetadata-ui/src/main/resources/ui/src/utils/<Entity>DetailUtils.tsx`
Import required components:
```typescript
import ErrorPlaceHolder from '../components/common/ErrorWithPlaceholder/ErrorPlaceHolder';
import SampleDataTableComponent from '../components/Database/SampleDataTable/SampleDataTable.component';
import { ERROR_PLACEHOLDER_TYPE } from '../enums/common.enum';
```
Add SAMPLE_DATA tab in the tabs array (only if entity has schema/columns):
```typescript
export const getEntityDetailPageTabs = ({
// ... props destructured
viewSampleDataPermission,
entityPermissions,
entityData,
}: EntityDetailPageTabProps) => {
return [
// ... existing tabs (SCHEMA, etc.)
// Add SAMPLE_DATA tab conditionally
...(!isSchemaEmpty // Only show if entity has columns
? [
{
label: (
<TabsLabel
id={EntityTabs.SAMPLE_DATA}
name={get(
labelMap,
EntityTabs.SAMPLE_DATA,
t('label.sample-data')
)}
/>
),
key: EntityTabs.SAMPLE_DATA,
children: !viewSampleDataPermission ? (
<ErrorPlaceHolder
className="border-none"
permissionValue={t('label.view-entity', {
entity: t('label.sample-data'),
})}
type={ERROR_PLACEHOLDER_TYPE.PERMISSION}
/>
) : (
<SampleDataTableComponent
entityType={EntityType.YOUR_ENTITY}
isTableDeleted={deleted}
owners={entityData?.owners ?? []}
permissions={entityPermissions}
tableId={entityData?.id ?? ''}
/>
),
},
]
: []),
// ... rest of tabs (ACTIVITY_FEED, LINEAGE, etc.)
];
};
```
#### 4.4 Update Entity Page to Pass Sample Data Permission
**Location:** `openmetadata-ui/src/main/resources/ui/src/pages/<Entity>Page/<Entity>Page.tsx`
Add `viewSampleDataPermission` to permissions useMemo:
```typescript
const {
editCustomAttributePermission,
editLineagePermission,
viewBasicPermission,
viewAllPermission,
viewCustomPropertiesPermission,
viewSampleDataPermission, // Add this
} = useMemo(
() => ({
// ... existing permissions
viewSampleDataPermission: getPrioritizedViewPermission(
entityPermissions,
Operation.ViewSampleData
),
}),
[entityPermissions, deleted]
);
```
Pass the permission to tabs:
```typescript
const tabs = useMemo(() => {
const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs);
const tabs = entityDetailsClassBase.getEntityDetailPageTabs({
// ... existing props
viewSampleDataPermission,
entityPermissions,
entityData,
// ... rest
});
return getDetailsTabWithNewLabel(tabs, customizedPage?.tabs, EntityTabs.SCHEMA);
}, [
// ... existing dependencies
viewSampleDataPermission,
entityPermissions,
entityData,
]);
```
#### 4.5 Import Generated Schema for Ingestion Pipeline
**Location:** `openmetadata-ui/src/main/resources/ui/src/utils/IngestionWorkflowUtils.ts`
```typescript
import yourServiceAutoClassificationPipeline from '../jsons/ingestionSchemas/yourServiceAutoClassificationPipeline.json';
```
#### 4.6 Add Schema Routing Logic
**Location:** `openmetadata-ui/src/main/resources/ui/src/utils/IngestionWorkflowUtils.ts`
Find the function that maps service categories to schemas (e.g., `getAutoClassificationSchemaByServiceCategory`):
```typescript
export const getAutoClassificationSchemaByServiceCategory = (
serviceCategory: ServiceCategory
): RJSFSchema => {
switch (serviceCategory) {
case ServiceCategory.DATABASE_SERVICES:
return databaseAutoClassificationPipeline as RJSFSchema;
case ServiceCategory.STORAGE_SERVICES:
return storageAutoClassificationPipeline as RJSFSchema;
case ServiceCategory.MESSAGING_SERVICES: // Your new service
return messagingAutoClassificationPipeline as RJSFSchema;
default:
return databaseAutoClassificationPipeline as RJSFSchema;
}
};
```
#### 4.7 Verify Pipeline Type Filtering
**Location:** `openmetadata-ui/src/main/resources/ui/src/utils/IngestionUtils.ts`
Ensure your service category supports the AutoClassification pipeline type:
```typescript
export const getSupportedPipelineTypes = (
serviceDetails: ServiceData,
serviceCategory: ServiceCategory
): PipelineType[] => {
const connectionConfig = serviceDetails.connection?.config;
const pipelineTypes: PipelineType[] = [];
// Metadata ingestion
if (connectionConfig?.supportsMetadataExtraction) {
pipelineTypes.push(PipelineType.Metadata);
}
// Auto-classification (profiler support)
if (connectionConfig?.supportsProfiler) {
pipelineTypes.push(PipelineType.AutoClassification);
}
return pipelineTypes;
};
```
---
### 5. Testing
#### 5.1 Python Unit Tests
**Location:** `ingestion/tests/unit/<domain>/test_<entity>_fetcher.py`
**Test fetcher strategy:**
```python
from metadata.profiler.source.fetcher.fetcher_strategy import YourEntityFetcherStrategy
class TestYourEntityFetcher:
def test_filter_entities_with_pattern(self):
"""Test entity filtering with inclusion/exclusion patterns"""
# Setup config with filter pattern
# Create mock entities
# Assert filtered results match expectations
def test_filter_entities_without_columns(self):
"""Test that entities without columns are filtered out"""
# Create entity without columns
# Assert it's filtered out
```
**Location:** `ingestion/tests/unit/sampler/test_<entity>_sampler_processor.py`
**Test sampler processor:**
```python
class TestYourEntitySamplerProcessor:
def test_process_entity_with_columns(self):
"""Test processing entity with valid column schema"""
# Create entity with columns
# Mock sampler to return sample data
# Assert SamplerResponse contains expected data
def test_skip_entity_without_columns(self):
"""Test that entities without columns are skipped"""
# Create entity without columns
# Assert processor returns Empty Either
```
#### 5.2 Python Integration Tests
**Location:** `ingestion/tests/integration/auto_classification/<entities>/test_<entity>_classification.py`
**Test end-to-end classification:**
```python
class TestYourEntityClassification:
@pytest.fixture
def setup_service(self):
"""Setup test service with sample data"""
# 1. Create test service
# 2. Create test entities with known PII data
# 3. Yield for test execution
# 4. Cleanup
def test_classification_detects_pii(self, setup_service):
"""Test that PII is correctly detected and tagged"""
# Run classification workflow
# Assert PII tags are applied to correct columns
def test_sample_data_storage(self, setup_service):
"""Test sample data is stored when configured"""
# Run workflow with storeSampleData=true
# Retrieve entity via API
# Assert sampleData field is populated
def test_pii_masking(self, setup_service):
"""Test PII masking for unauthorized users"""
# Create entity with PII tags
# Retrieve as unauthorized user
# Assert sensitive values are masked
```
**Location:** `ingestion/tests/integration/auto_classification/<entities>/conftest.py`
**Setup test fixtures:**
```python
@pytest.fixture(scope="module")
def create_test_service():
"""Create test service with sample entities"""
# Setup service
# Create entities
yield
# Cleanup
```
#### 5.3 Java Integration Tests
**Location:** `openmetadata-service/src/test/java/org/openmetadata/service/resources/<domain>/<Entity>ResourceTest.java`
**Test sample data endpoints:**
```java
@Test
void test_addSampleData() {
Entity entity = createEntity(createRequest("test"), ADMIN_AUTH_HEADERS);
TableData sampleData = new TableData()
.withColumns(List.of("col1", "col2"))
.withRows(List.of(
List.of("value1", "value2"),
List.of("value3", "value4")
));
Entity updated = addSampleData(entity.getId(), sampleData, ADMIN_AUTH_HEADERS);
assertEquals(sampleData, updated.getSampleData());
}
@Test
void test_getSampleData_withPIIMasking() {
// Create entity with PII tags
// Add sample data
// Retrieve as user without PII access
// Assert data is masked
}
@Test
void test_deleteSampleData() {
// Create entity
// Add sample data
// Delete sample data
// Assert sample data is null
}
```
---
## Validation Checklist
Before submitting your PR, verify:
### Schema Layer
- [ ] Entity schema includes `sampleData` field
- [ ] Auto-classification pipeline schema created for service type
- [ ] Pipeline schema registered in `workflow.json`
- [ ] All connector connection schemas include `supportsProfiler`
- [ ] Schemas rebuilt with `mvn clean install` in `openmetadata-spec/`
### Backend (Java)
- [ ] Repository implements `addSampleData`, `getSampleData`, `deleteSampleData`
- [ ] Resource exposes REST endpoints for sample data operations
- [ ] Resource includes `sampleData` in fields list
- [ ] Resource declares `VIEW_SAMPLE_DATA` and `EDIT_SAMPLE_DATA` operations
- [ ] PIIMasker extended to support new entity type
- [ ] AutoClassificationBotPolicy includes new entity
- [ ] Database migration updates bot policy
- [ ] Java code formatted with `mvn spotless:apply`
### Ingestion (Python)
- [ ] `ClassifiableEntityType` union in `pii/types.py` includes new entity
- [ ] `EntityAdapter` subclass added in `sampler/entity_adapters.py` with correct `pipeline_config_class`, `service_type`, `patch_fields`, `get_columns`, `set_columns`, `build_sampler_kwargs`
- [ ] `build_sampler_kwargs` returns pre-resolved sampling values (`sample_config`, `sample_data_count`, etc.) — do NOT include `schema_entity`, `database_entity`, or `table_config` for non-database entities; those are database-specific and no longer part of `SamplerInterface.create()`
- [ ] New adapter registered in `_BY_ENTITY` and `_BY_PIPELINE` dicts in `entity_adapters.py`
- [ ] Fetcher strategy created for service type
- [ ] Sampler implementation created for connector(s)
- [ ] OMetaMixin created for sample data ingestion (`ingest_your_entity_sample_data`)
- [ ] Mixin registered in `OpenMetadata` class
- [ ] `_ingest_entity_sample_data` `@register` added for new entity type in `metadata_rest.py`
- [ ] Service spec registers sampler class
- [ ] `workflow/classification.py` isinstance tuple extended with new pipeline config class
- [ ] Code formatted with `make py_format`
- [ ] Type checks pass with `make static-checks`
### Frontend (TypeScript)
- [ ] Sample data API methods added to `<entity>API.ts` (GET and DELETE `/sampleData`)
- [ ] `SampleDataTable.interface.ts` updated with `entityType` prop
- [ ] `SampleDataTable.component.tsx` updated to support multiple entity types
- [ ] `<Entity>DetailsClassBase.ts` includes `viewSampleDataPermission` in interface
- [ ] `<Entity>DetailsClassBase.ts` includes `SAMPLE_DATA` in tab IDs list
- [ ] `<Entity>DetailUtils.tsx` imports `SampleDataTableComponent` and error placeholder
- [ ] `<Entity>DetailUtils.tsx` adds SAMPLE_DATA tab with permission check
- [ ] `<Entity>DetailUtils.tsx` passes `entityType` prop to `SampleDataTableComponent`
- [ ] `<Entity>Page.tsx` computes `viewSampleDataPermission` from `Operation.ViewSampleData`
- [ ] `<Entity>Page.tsx` passes `viewSampleDataPermission` to tabs function
- [ ] `<Entity>Page.tsx` passes entity permissions and data to tabs function
- [ ] Schema imported in `IngestionWorkflowUtils.ts`
- [ ] Schema routing logic added for service category
- [ ] Pipeline type filtering supports AutoClassification
- [ ] Generated TypeScript models committed
### Testing
- [ ] Unit tests for fetcher strategy
- [ ] Unit tests for sampler processor
- [ ] Integration tests for end-to-end classification
- [ ] Java integration tests for REST endpoints
- [ ] Tests verify PII masking behavior
- [ ] All tests pass
---
## Common Pitfalls
1. **Forgetting to rebuild schemas**: After changing JSON schemas, always run `mvn clean install` in `openmetadata-spec/`
2. **Inconsistent column access patterns**: Different entities store columns differently:
- `Table`: `entity.columns`
- `Container`: `entity.dataModel.columns`
- `Topic`: `entity.messageSchema.schemaFields`
Define `get_columns` and `set_columns` correctly in your adapter — that is the only place this logic lives. The PII processor, sampler processor, and patch mixin all delegate to the adapter automatically.
3. **Missing service type detection**: The sampler processor looks up the `ServiceType` via `adapter_for_pipeline(source_config)`. If your new `pipeline_config_class` is not registered in `_BY_PIPELINE` in `entity_adapters.py`, the processor will raise a `ValueError` at startup. Register it before testing.
4. **Incomplete filter patterns**: Each service type needs entity-specific filters (e.g., `bucketFilterPattern`, `topicFilterPattern`). Don't just copy database patterns.
5. **Authorization gaps**: Always check both operations:
- `VIEW_SAMPLE_DATA`: Controls visibility of sample data
- `EDIT_SAMPLE_DATA`: Controls ability to add/delete sample data
6. **Frontend schema resolution**: Connection schemas must use `supportsProfiler` for the UI to show auto-classification option.
7. **PII masking logic**: Ensure `maskSampleData` handles both entity-level and column-level PII tags.
8. **`storeSampleData` defaults to `false`**: Sample data will NOT be ingested unless `storeSampleData: true` is explicitly set in the pipeline configuration. This is by design to avoid storing potentially large sample datasets by default. The sink only ingests sample data when `record.sample_data.store` is true.
9. **Service type not found at startup**: If you see `ValueError: Could not determine service type from config`, the pipeline config class is not registered in `_BY_PIPELINE` in `entity_adapters.py`. Register it there — the sampler processor does not need any code changes.
10. **Sample data not dispatched in sink**: `_ingest_entity_sample_data` in `metadata_rest.py` uses `@singledispatchmethod`. If you forget to add a `@register` for your entity type, calling it raises `NotImplementedError` and sample data is silently skipped. The column tag path (`patch_column_tags`) is fully adapter-driven and needs no sink changes — but sample data storage does require its own `@register`.
11. **Passing `schema_entity=None` explicitly in non-database adapters:** `SamplerInterface.create()` no longer accepts `schema_entity`, `database_entity`, or `table_config`. These were removed from the interface entirely. Non-database adapters should return already-resolved values (`sample_config`, `sample_data_count`, `partition_details`, etc.) directly in `build_sampler_kwargs()` — not the database hierarchy params.
---
## Troubleshooting
### Sample Data Not Appearing
**Symptom:** GET `/api/v1/<entities>/{id}/sampleData` returns empty or the entity without `sampleData` field.
**Possible causes:**
1. **`storeSampleData` is disabled**: Check your pipeline configuration. The default is `false`.
```bash
# Check pipeline config
http GET http://localhost:8585/api/v1/services/ingestionPipelines/{pipeline-id}
# Look for:
"sourceConfig": {
"config": {
"storeSampleData": false # <- This must be true!
}
}
```
2. **Sample data not in database**: Check the `entity_extension` table:
```sql
SELECT id, extension, jsonSchema
FROM entity_extension
WHERE extension = '<entity>.sampleData'
LIMIT 10;
```
If no rows exist, sample data was never ingested. Check workflow logs for errors.
3. **Workflow didn't run or failed**: Check ingestion pipeline execution logs for errors during sampling or PII detection.
4. **Service type detection failed**: Look for import errors in logs like:
```
Cannot import metadata.ingestion.source.database.<connector>
```
This means the adapter registry resolved the wrong service type. Verify your pipeline config class is registered in `_BY_PIPELINE` in `sampler/entity_adapters.py` with the correct `service_type`.
### Module Import Errors
**Symptom:** `DynamicImportException: Cannot import metadata.ingestion.source.database.<connector>`
**Cause:** The sampler processor resolved `ServiceType.Database` instead of the correct service type (e.g., `ServiceType.Storage`). This means `adapter_for_pipeline(source_config)` returned `None` or the wrong adapter.
**Solution:**
1. Verify your new `pipeline_config_class` is registered in `_BY_PIPELINE` in `ingestion/src/metadata/sampler/entity_adapters.py`
2. Check that the adapter's `service_type` field is set to the correct `ServiceType`
3. Confirm the pipeline schema is listed in `workflow.json` so it's properly deserialized from config
### PII Tags Not Applied
**Symptom:** Sample data is ingested but no PII tags appear on columns.
**Possible causes:**
1. **`enableAutoClassification` is disabled**: Check pipeline config has `enableAutoClassification: true`
2. **Confidence threshold too high**: Lower the `confidence` value in pipeline config (default is 80)
3. **Sample data count too low**: Increase `sampleDataCount` for better PII detection accuracy
4. **Column name mismatch**: Verify column names in sample data match entity column definitions exactly
---
## Reference Implementation
For a complete reference, see [PR #26495: Container Auto-Classification Support](https://github.com/open-metadata/OpenMetadata/pull/26495)
Key commits:
1. Schema changes (entity, pipeline config, workflow registration)
2. Backend support (repository, resource, PII masking, policy)
3. Type system extension (ClassifiableEntityType union)
4. Python ingestion (fetcher, sampler, processor, sink)
5. Frontend routing (schema import and service category mapping)
6. Integration tests (end-to-end classification workflow)
---
## Getting Help
If you encounter issues:
1. Review existing adapter implementations: `TableAdapter`, `ContainerAdapter` in `ingestion/src/metadata/sampler/entity_adapters.py`
2. Check type definitions in `ingestion/src/metadata/pii/types.py`
3. Examine sampler interface: `ingestion/src/metadata/sampler/sampler_interface.py`
4. Review fetcher strategies: `ingestion/src/metadata/profiler/source/fetcher/fetcher_strategy.py`
For questions, reach out on the OpenMetadata Slack community.