Customer Loyalty Milestone: Workbook
Business context and architecture | Download package | Implementation
Prepare campaign candidates when a customer crosses the Gold spending threshold. Activator publishes the milestone; Eventstream adds campaign and benefit context, excludes inactive campaigns and saves the useful results as Lakehouse Delta tables. This exercise prepares marketing work, not an issued reward or a delivered message.
Resources and coverage
| Resource | Default item name | What the package prepares |
|---|---|---|
| Event Schema Set | RetailCustomers |
Six-field Retail.Customer.MilestoneReached event and delegated Publish/Consume permissions |
| Source Eventhouse and KQL database | LoyaltyData, LoyaltySource |
Empty CustomerSpendSnapshots table for customer-wide balances in USD |
| KQL Queryset | LoyaltyQueries |
Spend observations and Current customer spend tabs |
| Lakehouse | LoyaltyCampaigns |
Lakehouse with schemas disabled; campaign catalog and output tables are populated later |
| Catalog setup Notebook | LoyaltyCatalogSetup |
Prepared catalog and destination; run it once manually before connecting reference data |
| Activator publisher | LoyaltyPublisher |
Create the customer-keyed threshold rule and Business Event action manually |
| Eventstream | LoyaltyCampaignStream |
Create the native subscription, reference join, filters and Delta destinations manually |
| Independent Business Event history | Not configured by this package | The source Eventhouse is not a milestone-history consumer |
The package creates six Fabric items. The two configured names for Activator and Eventstream are reserved for your manual setup; those connections are not created by deployment. The public Eventstream definition API does not expose Business Events sources.
Before you start
Use Python 3.10+, Azure CLI and PowerShell on Windows, an active supported Fabric capacity, and Contributor or higher access to the workspace. Use that workspace for the schema set and Eventstream so they share the required region. You also need permission to run Spark Notebooks and read and write the Lakehouse.
1. Configure your environment
Download customer-loyalty-milestone.zip or the repository ZIP, extract it, and open its cicd\customer-loyalty-milestone folder.
Replace the three placeholders below:
Set-Location '<extracted-folder>\cicd\customer-loyalty-milestone'
$tenantId = '<your-tenant-uuid>'
$workspaceId = '<your-workspace-uuid>'
az login --tenant $tenantId
python .\deploy.py configure --tenant-id $tenantId --workspace-id $workspaceId --auth-provider azure_cli --output .\config.local.json
Review the tenant, workspace and unused resource names in config.local.json. accept_preview_apis defaults to true; set it to false to block cloud operations if you do not accept preview APIs.
Choose unused names for all eight roles. Six belong to the prepared resources; use the other two when creating Activator and Eventstream.
2. Deploy to Fabric
Open the configured workspace and find the six prepared items listed above. Deployment leaves the source table empty and does not run the catalog Notebook, publish milestones, create campaign output records or configure the two manual connections. Continue with Step 3 before testing.
3. Connect the milestone to campaign preparation
Use your configured names wherever they differ from the defaults.
3.1. Make the campaign catalog available
- Open
LoyaltyCatalogSetup, of type Notebook, in the deployed workspace. - Attach
LoyaltyCampaignsas its default Lakehouse and select Run all once. - In
LoyaltyCampaigns → Tables, openLoyaltyCampaignCatalog. Confirm the two rows: Gold atstore-westhas active campaigngold-west-welcome; Gold atstore-easthas inactive campaigngold-east-welcome. Both use benefit codeGOLD_WELCOME. - Keep
store-northunmapped for this exercise. Each tier/store pair must have exactly one catalog row.
The Notebook creates only the reference catalog. It does not publish milestones or write candidates. It will not overwrite an existing catalog when rerun.
3.2. Create the milestone publisher
Open the prepared LoyaltyQueries Queryset against LoyaltySource. The source table and both query tabs already exist; do not recreate them. Use your configured names wherever they differ from the defaults.
- If the query needs sample rows to expose its fields, run only the baseline block in Step 4. Do not run the increased balances yet.
- In Spend observations, select Set alert and create
LoyaltyPublisher. In Activator, useobserved_atas the event time and onlycustomer_idas the object identity. Store, name, spend and timestamp are changing properties of that customer. - Monitor
total_spend. Choose Becomes greater than, or Increases above in the rule editor, with value5000. Do not choose a condition that fires for every observation already above5000. -
Configure Publish a business event, select
RetailCustomersandRetail.Customer.MilestoneReached, and map:Event field Source property customer_idcustomer_idcustomer_namecustomer_namemilestone_tiermilestone_tiertotal_spendtotal_spendstore_idstore_iddetected_atdetected_at -
Save the rule. Keep it stopped until the campaign flow below is ready.
Keep balances at or below 5000 in the source. They allow Activator to recognize a later crossing. A changing store must not create a new object; this is one customer's program-wide milestone.
See publishing Business Events from Activator.
3.3. Add campaign enrichment and Delta destinations
- In Real-Time Hub, open
Retail.Customer.MilestoneReachedunderRetailCustomers. Select Add consumer → Eventstream, use the same workspace, name itLoyaltyCampaignStreamand select Connect. Use the native Business Event source, not a custom endpoint. - Open the Eventstream in Edit mode. Add a reference source with Add source → Reference data sources → Select from OneLake. Choose
LoyaltyCampaigns → Tables → LoyaltyCampaignCatalog, select all five catalog columns and save. -
Add a Join operation. Use the live Business Event stream on the left and the reference catalog on the right. Choose Left outer, with these two field pairs:
Milestone field Catalog field milestone_tiercatalog_tierstore_idcatalog_store_id -
From the joined stream, create the two branches below. Select the milestone's payload properties if a field picker shows them under
data; do not use CloudEvents envelope fields. In Manage Fields, use the flat output column names shown below and check the resulting fields in preview before adding the destinations.Branch Filter Manage Fields: keep only Lakehouse table Campaign candidates campaign_idis not null andis_activeequals booleantruecustomer_id,milestone_tier,total_spend,store_id,detected_at,campaign_id,benefit_codeLoyaltyCampaignCandidatesCampaign mapping review campaign_idis nullcustomer_id,milestone_tier,total_spend,store_id,detected_atLoyaltyCampaignReview -
After each Manage Fields operation, select Add destination → Lakehouse, choose
LoyaltyCampaigns, select JSON as the input data format and create the corresponding Delta table. For the small demonstration, use Minimum rows: 1 and Maximum duration: 1 minute. These settings favor visible sample results over efficient production-sized files. - Publish the Eventstream. Confirm that the reference catalog is loaded and that both destination branches are running before starting the milestone exercise. Select a reference refresh schedule appropriate to later campaign changes; an edited catalog does not take effect until Eventstream refreshes it.
An inactive match is intentionally excluded from both outputs: it has a campaign mapping, but that campaign is not accepting candidates. An unmatched milestone goes to review so the team can decide whether to add a mapping. customer_name is not copied into either output because campaign preparation needs the customer ID, not another copy of their name.
This connection is manual because the public Eventstream definition API does not expose Business Events sources. See Eventstream transformations and Lakehouse destinations.
Then start the rule in LoyaltyPublisher. No consumer Activator, Power Automate flow or CRM connection is needed.
4. Run the balances and inspect campaign results
Start LoyaltyPublisher only after the Eventstream and both destinations are ready. Run each source block separately, keeping time to observe the results between blocks. Use a fresh prefix instead of loyalty-demo-001 throughout all three blocks when repeating the exercise.
Establish the starting balances
In LoyaltyQueries, open a new tab connected to LoyaltySource and run:
.set-or-append CustomerSpendSnapshots <|
let sample_id = 'loyalty-demo-001';
datatable(customer_suffix:string, store_id:string, total_spend:real)
[
'eligible', 'store-east', 4900.0,
'inactive', 'store-west', 4900.0,
'unmatched', 'store-west', 4900.0,
'boundary', 'store-west', 4900.0,
'below', 'store-west', 4900.0
]
| project customer_id = strcat(sample_id, '-', customer_suffix),
customer_name = strcat('Sample ', customer_suffix), total_spend, store_id, observed_at = now()
In LoyaltyPublisher, select the source event's Live feed and the customer object's properties. Wait until Activator has observed 4900 for all five IDs. Seeing the rows in the KQL database alone does not establish that the rule has seen them. No milestone or campaign candidate should exist for these starting balances.
Introduce the new balances
After confirming those starting values in Activator, run:
.set-or-append CustomerSpendSnapshots <|
let sample_id = 'loyalty-demo-001';
datatable(customer_suffix:string, store_id:string, total_spend:real)
[
'eligible', 'store-west', 5100.0,
'inactive', 'store-east', 5100.0,
'unmatched', 'store-north', 5100.0,
'boundary', 'store-west', 5000.0,
'below', 'store-west', 4999.0
]
| project customer_id = strcat(sample_id, '-', customer_suffix),
customer_name = strcat('Sample ', customer_suffix), total_spend, store_id, observed_at = now()
| Customer suffix | New balance | What you should see |
|---|---|---|
eligible |
5100 |
Gold milestone for store-west; candidate with campaign gold-west-welcome and benefit GOLD_WELCOME |
inactive |
5100 |
Gold milestone for store-east; inactive campaign excluded from both output tables |
unmatched |
5100 |
Gold milestone for store-north; record in LoyaltyCampaignReview, not the candidate table |
boundary |
5000 |
No milestone and no output; the rule requires spending above 5000 |
below |
4999 |
No milestone and no output |
The first three customers change stores while keeping their original customer IDs. Their balances remain customer-wide. In Business Event Data preview and Eventstream, look for those three milestone customer IDs with tier gold and spend 5100. In the joined preview, compare the active, inactive and missing catalog results.
Confirm that continued spending is not another crossing
After observing the eligible customer's 5100 milestone, run:
.set-or-append CustomerSpendSnapshots <|
let sample_id = 'loyalty-demo-001';
print customer_id = strcat(sample_id, '-eligible'), customer_name = 'Sample eligible',
total_spend = 5200.0, store_id = 'store-west', observed_at = now()
Wait until Activator shows 5200 for that same customer. This observation should not publish another milestone: the customer was already above 5000. The campaign candidate still describes the original 5100 crossing.
Evaluation criteria
Open LoyaltyCampaigns → SQL analytics endpoint. Allow time for Lakehouse ingestion and table visibility, then inspect the two Delta tables for this exercise:
SELECT customer_id, milestone_tier, total_spend, store_id,
detected_at, campaign_id, benefit_code
FROM dbo.LoyaltyCampaignCandidates
WHERE customer_id LIKE 'loyalty-demo-001-%';
SELECT customer_id, milestone_tier, total_spend, store_id, detected_at
FROM dbo.LoyaltyCampaignReview
WHERE customer_id LIKE 'loyalty-demo-001-%';
Expect one distinct candidate customer, loyalty-demo-001-eligible, and one distinct review customer, loyalty-demo-001-unmatched. Both retain the gold, 5100, store and observation time from their milestone. No inactive, boundary or below-threshold customer belongs in the candidate table. Continued spending at 5200 should not create a new milestone or replace the original candidate.
The tables are append outputs, not an exactly-once audience or a reward ledger. Repeated deliveries can create duplicate rows; compare customer IDs and milestone context rather than treating every row as another customer. Before outreach, select one candidate per customer, tier and campaign, check contact permission and confirm benefit eligibility.
These records do not show reward issuance, membership updates, message delivery or improved retention.
Implementation reference
Expand the details you need to understand or adapt the solution. This is reference, not a second setup tutorial. The package's default schema-set name is RetailCustomers. Standalone examples may use the original illustrative alias; match the connection alias to your configured schema set before reusing code. Event names and payload fields remain unchanged.
Event contract
The shared event fields are unchanged. The package prepares this contract; inspect it rather than creating a second event.
{
"type": "record",
"name": "Retail.Customer.MilestoneReached",
"fields": [
{"name": "customer_id", "type": "string", "doc": "Unique identifier of the customer"},
{"name": "customer_name", "type": "string", "doc": "Full name of the customer"},
{"name": "milestone_tier", "type": "string", "doc": "Loyalty tier reached: silver, gold, or platinum"},
{"name": "total_spend", "type": "float", "doc": "Cumulative spend amount that triggered this milestone"},
{"name": "store_id", "type": "string", "doc": "Identifier of the store where the milestone was reached"},
{"name": "detected_at", "type": "string", "doc": "Timestamp when the milestone was detected, ISO 8601 format"}
]
}
Source and publisher reference
These snippets explain the source and publication logic. Use the prepared publisher for the main exercise; standalone examples are not an additional source run.
.create-merge table CustomerSpendSnapshots (customer_id:string, customer_name:string, total_spend:real, store_id:string, observed_at:datetime)
declare query_parameters(startTime:datetime = datetime(1970-01-01), endTime:datetime = datetime(9999-12-31));
CustomerSpendSnapshots
| where observed_at >= startTime and observed_at <= endTime
| extend milestone_tier = 'gold',
detected_at = strcat(format_datetime(observed_at, 'yyyy-MM-dd'), 'T', format_datetime(observed_at, 'HH:mm:ss.fff'), 'Z')
| project observed_at, customer_id, customer_name, milestone_tier, total_spend, store_id, detected_at
| sort by observed_at asc
Response and mapping reference
Use these field and response details to understand the prepared resources. Complete only the connections identified in the main steps.
catalog_rows = [
("gold", "store-west", "gold-west-welcome", "GOLD_WELCOME", True),
("gold", "store-east", "gold-east-welcome", "GOLD_WELCOME", False),
]
catalog_schema = (
"catalog_tier string, catalog_store_id string, campaign_id string, "
"benefit_code string, is_active boolean"
)
(
spark.createDataFrame(catalog_rows, catalog_schema)
.write.format("delta").mode("errorifexists")
.option("delta.columnMapping.mode", "none")
.option("delta.enableDeletionVectors", "false")
.save("Tables/LoyaltyCampaignCatalog")
)
Further reading
What are you building?
Ask a question, share your scenario, or help shape the next developer guide.
Join the conversation on Reddit →