End-to-end mock of a document-publishing pipeline. Trace context propagates W3C-style from the publisher through Service Bus into the function and across both outbound HTTP calls, so the App Insights end-to-end map lights up under a single operation ID.
sequenceDiagram
autonumber
participant CLI as Publisher CLI
participant SB as Service Bus topic
participant Fn as Function (Flex Consumption)
participant SP as SharePoint mock (ACA)
participant SC as Sitecore mock (ACA)
CLI->>SB: SendMessage<br/>(traceparent in ApplicationProperties)
SB->>Fn: ServiceBusTrigger<br/>(parent activity continued)
Fn->>SP: GET /sites/{s}/drives/{d}/items/{i}
SP-->>Fn: DriveItem (200) or 404
Fn->>SP: GET .../items/{i}/content
SP-->>Fn: bytes (200) or 404
Fn->>SC: POST /sitecore/api/items<br/>(base64 payload + metadata)
SC-->>Fn: 200
Fn-->>Fn: TrackEvent("DocumentPublished",<br/>siteId/driveId/itemId/<br/>sizeBytes/durationMs)
The function is triggered by Microsoft.Graph.ListUpdated-shaped CloudEvents
landing on a Service Bus topic (in the real world: published by Microsoft Graph
through an Event Grid partner topic). It calls SharePoint to fetch the
referenced drive item metadata + content, then POSTs the file as a base64 blob
to "Sitecore".
Identity-based connections everywhere in Azure (no SAS, no account keys). The local Service Bus emulator is the only path that uses SAS, because that's the only auth mode the emulator supports.
| Concern | Tool |
|---|---|
| Function runtime | .NET 10, isolated worker, Flex Consumption |
| Mocks | ASP.NET minimal API, Azure Container Apps |
| Container registry | Azure Container Registry (Basic) |
| Messaging | Azure Service Bus Standard (topic) |
| Observability | Workspace-based App Insights + LAWS |
| IaC | OpenTofu, AzureRM provider 4.x |
| CI/CD | GitHub Actions, OIDC, no secrets |
| Local message bus | Microsoft Service Bus emulator (Docker) |
src/
Shared/ DTOs + HttpClient-based SharePoint / Sitecore clients
Function/ SB-triggered Flex Consumption function (+ dead-letter handler)
SharePointMock/ ASP.NET minimal API, seeded DocumentStore + /_seed/catalog
SitecoreMock/ ASP.NET minimal API receiving uploads (offline fallback)
Publisher/ CLI that publishes mock events to the SB topic
infra/
bootstrap/ One-time SP + federated creds + RG + state RBAC
modules/ naming, observability, service-bus, container-*, function-app, diagnostic-settings
environments/dev/ Calls modules into the workload RG
local/
service-bus-emulator/ docker-compose for local SB emulator
.github/workflows/
infra.yml tofu plan/apply/destroy
app.yml build + deploy function
mocks.yml build + push images, update ACA revisions
lint.yml tofu fmt + dotnet format on every PR
No keys, no connection strings (except the local emulator). Every Azure-side hop authenticates as a managed identity:
- GitHub Actions → Azure — federated OIDC. The bootstrap stack creates an
Entra app with three federated credentials (
mainpush,pull_request,environment:dev). Repo secrets are justAZURE_CLIENT_ID,AZURE_TENANT_ID,AZURE_SUBSCRIPTION_ID— no client secret exists. - Function → Service Bus / Storage — the function app's system-assigned
managed identity is
Azure Service Bus Data Receiveron the subscription andStorage Blob Data Owneron its own AzureWebJobsStorage account. - Function → SharePoint/Sitecore mock — plain HTTP today; container apps are reachable on the internal CAE network. Easy to put auth in front later.
- Container Apps → ACR — each ACA has a user-assigned identity with
AcrPullon the registry; ACR hasanonymousPullEnabled = false. - Publisher CLI → Service Bus —
DefaultAzureCredential. Locally that's youraz login; in CI it's the workflow's federated identity.
Run locally with your own Azure credentials. Creates the GitHub Actions Entra app, federated credentials, workload resource group, and the RBAC needed for GHA to take over.
az login --tenant 76de2d2d-77f8-438d-9a87-01806f2345da
az account set --subscription 8bd05b2f-62c5-4def-9869-f0617ebb3970
cd infra/bootstrap
cp terraform.tfvars.example terraform.tfvars
tofu init
tofu applyTake the three outputs (gha_client_id, gha_tenant_id, gha_subscription_id)
and add them as GitHub Actions repository secrets (AZURE_CLIENT_ID,
AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID). Also create a GitHub environment
named dev — the federated credential is scoped to it.
infra— runs on push tomain(or workflow_dispatch). Plans on PR. Provisions all Azure resources intorg-azfunc-pub-dev. Container apps come up on a public placeholder image.mocks— builds the two ASP.NET images, pushes to ACR (managed-identity auth), updates each container app revision in place.function-app— zips and deploys the function to the Flex Consumption plan.lint—tofu fmt -check+dotnet format --verify-no-changeson every PR; blocks formatting drift.
The container app image is ignore_changes'd in tofu so the mocks
workflow can roll new revisions without infra drift.
The whole pipeline emits one trace per published document. Spans:
- Publisher — root
PublishSharePointEventactivity (ActivityKind.Producer), tagged withmessaging.system=servicebus, the destination topic, plussharepoint.{siteId,driveId,itemId,fileName}andevent.id. - The W3C traceparent is written onto the Service Bus message as
ApplicationProperties["Diagnostic-Id"]/traceparent/tracestate. - Function — the Functions Worker AI extension picks up
Diagnostic-Idfrom the message and continues the trace. The function tags its invocation activity with the SharePoint dimensions too. - SharePoint mock + Sitecore mock —
Azure.Monitor.OpenTelemetry.AspNetCoreauto-instruments the incoming HTTP requests, so each appears as its own span under the function's call.
After a successful publish the function emits a custom event:
| Field | Source |
|---|---|
EventId |
CloudEvent id |
EventType |
CloudEvent type (Microsoft.Graph.ListUpdated) |
SiteId/DriveId/ItemId |
Event payload |
FileName/MimeType |
DriveItem returned by SharePoint mock |
sizeBytes |
Downloaded content length |
durationMs |
Stopwatch around the publish op |
Query it in App Insights:
customEvents
| where name == "DocumentPublished"
| extend siteId = tostring(customDimensions.SiteId),
fileName = tostring(customDimensions.FileName),
sizeBytes = todouble(customMeasurements.sizeBytes),
durationMs = todouble(customMeasurements.durationMs)
| project timestamp, siteId, fileName, sizeBytes, durationMs, operation_IdThe function topic subscription has max_delivery_count = 10 and dead_lettering_on_message_expiration = true. A second function, DeadLetterHandlerFunction, is bound to the subscription's $DeadLetterQueue path. It:
- Logs the dead-lettered message with reason + description + delivery count.
- Emits a
DocumentPublishingDeadLetteredcustom event with the same SharePoint dimensions if the body is parseable. - Survives non-JSON bodies (the enrichment is best-effort).
customEvents
| where name == "DocumentPublishingDeadLettered"
| extend reason = tostring(customDimensions.DeadLetterReason),
siteId = tostring(customDimensions.SiteId),
itemId = tostring(customDimensions.ItemId)
| order by timestamp desccd local/service-bus-emulator
cp .env.example .env # set ACCEPT_EULA=Y + strong MSSQL_SA_PASSWORD
docker compose up -ddotnet run --project src/SharePointMock --urls http://0.0.0.0:8081
dotnet run --project src/SitecoreMock --urls http://0.0.0.0:8082
# see what's seeded
curl http://localhost:8081/_seed/catalogcp src/Function/local.settings.json.example src/Function/local.settings.json
cd src/Function
func start # or: dotnet runlocal.settings.json is gitignored. It points the function at the local
emulator (SAS) and the local SharePoint/Sitecore mocks on http://localhost:8081
and http://localhost:8082.
The publisher's defaults already point at the SharePointMock's seeded
welcome.txt sample (site-default / drive-default / item-default), so the
happy path runs no-args once the emulator + mocks + function are up:
dotnet run --project src/Publisher -- \
--ServiceBus:ConnectionString="Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"To force a 404 path (publisher succeeds, function throws, message dead-letters after 10 retries):
dotnet run --project src/Publisher -- `
--ServiceBus:ConnectionString="..." `
--Event:ItemId=does-not-existaz login
dotnet run --project src/Publisher -- `
--ServiceBus:Namespace=sbns-azfunc-pub-dev.servicebus.windows.netUses DefaultAzureCredential — your az session is the principal. You'll need
Azure Service Bus Data Sender on the topic.
Grab a unique URL from https://webhook.site and set
sitecore_webhook_url in infra/environments/dev/terraform.tfvars, then apply.
The function will POST uploads there instead of to the Sitecore container app.
- Flex Consumption requires the
AzureWebJobsStoragesetting to be set to an empty string andAzureWebJobsStorage__accountName/__credential=managedidentity/__clientIdto be used for the actual storage connection. This is a currentazurermprovider quirk. - Service Bus topic role assignments must be scoped to the subscription resource, not the topic. The function module already does this.
- ACR must allow ARM audience tokens (default for new registries) for ACA managed-identity image pull to work.
dotnet formatenforces an initializer-indent rule that puts=> new() { ... }bodies two indent levels deeper than the lambda. The lint workflow will fail PRs that drift.