Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions telemetry-relay/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Never commit real secrets or local settings.
local.settings.json
node_modules/
.env
96 changes: 96 additions & 0 deletions telemetry-relay/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Telemetry relay (Azure Function)

A tiny serverless relay that forwards **anonymous, opt-in** install events from
the documentdb-agent-kit installers to Application Insights, so the App Insights
connection string **never ships in the public repo or the client**.

```
installer (install.ps1/.sh) ──POST──▶ this Function (/api/collect) ──▶ Application Insights ──▶ Grafana (Azure Monitor data source)
ships only a public URL holds the connection string
in app settings (server-side)
```

## What's here

| File | Purpose |
|---|---|
| `src/functions/collect.js` | HTTP-triggered relay: validates, allow-lists, rate-limits, forwards `trackEvent` |
| `host.json` | Functions host config |
| `package.json` | `@azure/functions` v4 + `applicationinsights` SDK |
| `main.bicep` | Deploys Log Analytics + App Insights + Function App + storage |
| `local.settings.json.sample` | Copy to `local.settings.json` for local runs (git-ignored) |

## Prerequisites

- Azure CLI (`az`), Azure Functions Core Tools (`func`), Node.js 20+
- An Azure subscription + permission to create resources

## 1. Deploy the infrastructure

```powershell
az login
az account set --subscription "<your-subscription-id>"

$rg = "documentdb-telemetry-rg"
az group create -n $rg -l westus2

az deployment group create -g $rg -f telemetry-relay/main.bicep -p namePrefix=ddbkit
```

Note the outputs — especially `collectUrl` (the endpoint the installers POST to)
and `appInsightsName` (used by the Grafana data source).

## 2. Publish the Function code

```powershell
cd telemetry-relay
npm install

# Function App name = the 'funcName' output from the deployment
func azure functionapp publish <ddbkit-func-xxxxxx>
```

## 3. Test locally (optional)

```powershell
Copy-Item local.settings.json.sample local.settings.json
# paste your App Insights connection string into local.settings.json (git-ignored)
npm install
func start
# in another shell:
curl -X POST http://localhost:7071/api/collect -H "Content-Type: application/json" `
-d '{"name":"skill_install","properties":{"kitVersion":"1.0.0","osFamily":"windows","skills":"indexing,vector-search"},"measurements":{"skillCount":2}}'
```

## 4. Point the installers at the relay

Set the relay URL in the installers (or via env var) instead of calling App
Insights directly — only the **URL** is public, never a key:

```powershell
./scripts/install.ps1 -Target claude-user -Telemetry -RelayUrl "https://<collectUrl>"
```

## 5. Grafana

Add an **Azure Monitor** data source in Grafana (managed identity + Monitoring
Reader), then query the custom events with KQL, e.g.:

```kusto
customEvents
| where name == "skill_install"
| mv-expand skill = split(tostring(customDimensions.skills), ",")
| summarize installs = count() by tostring(skill)
| order by installs desc
```

## Security notes

- The **connection string is only in Function app settings** (set by Bicep) — not
in this repo, not in the shipped installers.
- The endpoint is public, so the relay **allow-lists** event/property names, caps
body size, and applies a best-effort per-IP rate limit. Tune these in
`src/functions/collect.js`.
- The relay never returns client-visible errors (always `202`) so telemetry can't
break an install.
- Set an App Insights **daily cap** to bound cost against abuse.
15 changes: 15 additions & 0 deletions telemetry-relay/host.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"version": "2.0",
"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
}
}
},
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[4.*, 5.0.0)"
}
}
8 changes: 8 additions & 0 deletions telemetry-relay/local.settings.json.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "node",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"APPLICATIONINSIGHTS_CONNECTION_STRING": "<paste-your-app-insights-connection-string-here-for-LOCAL-testing-only>"
}
}
102 changes: 102 additions & 0 deletions telemetry-relay/main.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Deploys the telemetry relay: Log Analytics workspace, Application Insights,
// a consumption-plan Function App (Linux/Node), and its storage account.
// The App Insights connection string is wired into the Function's app settings
// automatically — it is never emitted to the repo or the client.
//
// Deploy:
// az group create -n <rg> -l <location>
// az deployment group create -g <rg> -f main.bicep -p namePrefix=<prefix>

@description('Short prefix for resource names (3-11 lowercase alphanumeric).')
@minLength(3)
@maxLength(11)
param namePrefix string

@description('Azure region for all resources.')
param location string = resourceGroup().location

var suffix = uniqueString(resourceGroup().id)
var storageName = toLower('${namePrefix}st${substring(suffix, 0, 6)}')
var planName = '${namePrefix}-plan'
var funcName = '${namePrefix}-func-${substring(suffix, 0, 6)}'
var lawName = '${namePrefix}-law'
var aiName = '${namePrefix}-ai'

resource law 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
name: lawName
location: location
properties: {
sku: { name: 'PerGB2018' }
retentionInDays: 90
}
}

resource ai 'Microsoft.Insights/components@2020-02-02' = {
name: aiName
location: location
kind: 'web'
properties: {
Application_Type: 'web'
WorkspaceResourceId: law.id
IngestionMode: 'LogAnalytics'
}
}

resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: storageName
location: location
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
properties: {
minimumTlsVersion: 'TLS1_2'
allowBlobPublicAccess: false
}
}

resource plan 'Microsoft.Web/serverfarms@2023-12-01' = {
name: planName
location: location
sku: { name: 'Y1', tier: 'Dynamic' }
properties: { reserved: true }
}

resource func 'Microsoft.Web/sites@2023-12-01' = {
name: funcName
location: location
kind: 'functionapp,linux'
identity: { type: 'SystemAssigned' }
properties: {
serverFarmId: plan.id
httpsOnly: true
siteConfig: {
linuxFxVersion: 'Node|20'
ftpsState: 'Disabled'
minTlsVersion: '1.2'
cors: {
allowedOrigins: ['*']
}
appSettings: [
{ name: 'FUNCTIONS_EXTENSION_VERSION', value: '~4' }
{ name: 'FUNCTIONS_WORKER_RUNTIME', value: 'node' }
{ name: 'WEBSITE_NODE_DEFAULT_VERSION', value: '~20' }
{
name: 'AzureWebJobsStorage'
value: 'DefaultEndpointsProtocol=https;AccountName=${storage.name};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storage.listKeys().keys[0].value}'
}
{
name: 'APPLICATIONINSIGHTS_CONNECTION_STRING'
value: ai.properties.ConnectionString
}
]
}
}
}

@description('POST install events to this URL from the installers.')
output collectUrl string = 'https://${func.properties.defaultHostName}/api/collect'

@description('Application Insights resource name (for the Grafana Azure Monitor data source).')
output appInsightsName string = ai.name

@description('Log Analytics workspace name (query custom events here / in Grafana).')
output logAnalyticsWorkspace string = law.name
Loading
Loading