Intune · Azure Monitor · Log Analytics · mTLS · PowerShell · Telemetry
LogCollector: device telemetry from Intune, with zero shared secrets
How a certificate-authenticated, spool-backed pipeline moves Intune device telemetry into Log Analytics without workspace keys, and the architecture choices and scars behind it.
- Drafted
- Published
- Reading time
- 6 min
- Author
- Roberto Gramellini
Source:
MSLabs editorial
· revision 59101cea
- Intune
- Azure Monitor
- Log Analytics
- mTLS
- PowerShell
- Telemetry
LogCollector: device telemetry from Intune, with zero shared secrets
Here is a sentence that should make any security person nervous: "The script just posts the data to Log Analytics using the workspace key." That key is a shared secret, baked into a script, on thousands of endpoints. One leaked copy and anyone can write anything into your workspace.
LogCollector is our answer: a platform that collects telemetry from Windows devices through Intune and lands it in Azure Monitor, where every device proves who it is on every single request. No workspace keys. No function keys. No shared secrets. Just certificates, signatures and a healthy dose of paranoia.
Why not "just use the workspace key"?
Because telemetry is not only inventory. Today it is hardware data. Tomorrow it is remediation results, health checks and whatever your next Intune script wants to report. We wanted one authenticated, resilient pipe that any producer could use, and the requirements were unforgiving:
- Devices are offline half the time (laptops are, by profession, wanderers).
- Payloads can be large, far beyond a queue message.
- The backend must know which device is talking, and refuse impostors and replays.
- Adding a new kind of telemetry must not mean writing a new service.
The architecture, front to back
On the device. Intune deploys a Core package (a Win32 app) that installs the client module and a protected endpoint configuration. Core collects nothing by itself; it is a dependency. Producers, such as the Inventory app, depend on it and run as scheduled tasks. Core and producer are separate on purpose, so a new telemetry producer is a new small package, not a platform rewrite.
At the edge. Telemetry goes over TLS 1.2+ with mutual TLS to a Linux App Service. The frontend revalidates the certificate chain, verifies a body signature, reserves the (certificate, nonce) pair in Table Storage to kill replays, checks the device identity, and enforces an allow-list of tables and streams.
Behind the edge. The frontend writes the payload to Blob Storage and puts only a pointer on a Service Bus queue. A .NET 10 isolated Function on Flex Consumption picks it up, verifies the SHA-256, adds server-asserted identity columns, chunks to 850 KB or less, and sends it through the Logs Ingestion API using a Data Collection Endpoint and a direct Data Collection Rule.
Device (cert + signature) --mTLS--> Frontend (validate, anti-replay, allow-list)
Frontend --> Blob (payload) + Service Bus (pointer)
Worker --> verify SHA-256 -> project rows -> chunk <= 850 KB
Worker --(Logs Ingestion API, managed identity)--> DCE/DCR --> Log Analytics table
Between services there are no secrets either: managed identity and RBAC, with storage key authentication switched off.
The design choices I would defend in a pub
The spool: patience as a feature
If a device cannot reach the backend, the data is not dropped. It goes to a durable local spool with atomic writes and strict limits: 7 days, 500 entries, 64 MB. When connectivity returns, the oldest entries are delivered first, and the spool is drained before new data is submitted, so old records are never starved by fresh ones. Permanent failures are quarantined instead of blocking everything behind them.
One subtlety that bit us: signatures are bound to a timestamp and a nonce, so a stored signature would be rejected by replay protection. Every retry therefore generates a fresh signature. Security features are wonderful until they reject your own retries.
Schema as configuration
A new telemetry purpose needs a table and a stream mapping, not new code paths. The Bicep takes a list of table definitions and stamps out the data flows:
dataFlows: [for table in telemetryTableDefinitions: {
streams: [ 'Custom-${table.name}' ]
destinations: [ 'inventoryWorkspace' ]
transformKql: 'source'
outputStream: 'Custom-${table.name}'
}
]
Pass-through KQL (source) keeps the DCR boring, and boring is the highest compliment infrastructure can receive.
Two schedules, one job each
Inventory runs on a Wednesday and Saturday morning schedule with a two-hour random delay, so ten thousand laptops do not stampede the endpoint at 09:00 sharp. A separate hourly task drains the spool.
$weekly = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Wednesday,Saturday -At '09:00' -RandomDelay (New-TimeSpan -Hours 2) $drain = New-ScheduledTaskTrigger -Once -At ((Get-Date).AddMinutes(5)) -RepetitionInterval (New-TimeSpan -Hours 1)
Battle scars
Honest list, because the interesting bits are always the scars.
- Flex Consumption cannot do client-certificate ingress, so the frontend and the worker are hosted differently on purpose: App Service for mTLS, Flex Consumption for scale-to-zero processing.
- The queue message limit forced the blob-plus-pointer pattern. Large inventory payloads do not fit in a 256 KB message, and no amount of optimism changes that.
- mTLS versus deployment. Mandatory client certificates also affect Kudu/SCM, so the deployment script temporarily adjusts access restrictions while keeping a fail-closed posture. Locking your own front door with the keys inside is a rite of passage.
- Names are global. Storage and Function names must be unique worldwide, so the customer prefix is chosen once, at first deployment, and chosen carefully.
- Device validation needs Graph permission (
Device.Read.All). You can turn it off, and the documentation says clearly that tenant-membership validation is then weaker. - Compatibility is a promise. Across releases the platform kept legacy signature formats, routes, tables, queues and spool entries working, while the backend, Core and Inventory versions evolved independently.
By the numbers
| Component | Version / value |
|---|---|
| Backend release | 1.15.2 |
| Core / client | 1.12.0 |
| Inventory | 1.9.1 |
| Ingestion chunk size | 850 KB or less |
| Worker retries | up to 5, 1 s base delay, 60 s cap |
| Tests | 26 Pester files, 24 C# test files |
The Pester suite runs each test file in its own process, so a stray module import in one test cannot flatter the next one.
Takeaways
- Prove identity per request. Certificates plus signed bodies beat any shared key, and replay protection closes the gap that signatures alone leave.
- Design for the offline device. A spool with explicit limits is not glamour, but it is the difference between telemetry and wishful thinking.
- Keep payloads out of queues. Pointers are cheap; surprises are not.
- Make schema a config change. If every new data type needs a deployment pipeline, you have built a bottleneck, not a platform.
The solution is public, so you can read the real code, the Bicep and the packaging scripts yourself. Bring your skepticism; it improves the pull requests.