---
description: 'Every collector graph2otel ships: what it collects, where it reads from,
  the permission it needs, license/beta gating, its poll interval, and what it…'
---

# Collector reference

Every collector `graph2otel` ships: what it collects, where it reads from, the permission it needs,
license/beta gating, its poll interval, and what it emits. For how to grant Graph scopes, see
[`permissions.md`](.././permissions.md); for the `collectors:` config block that enables/disables and
retunes them, see [`configuration.md`](.././configuration.md).

> **The tables below are generated from the collector registry** — a collector cannot ship
> undocumented, because `go test` fails when a registered one has no entry
> ([#139](https://github.com/rknightion/graph2otel/issues/139)). Regenerate with
> `scripts/regen-generated.sh collectordoc`. Everything outside the generated block, including every
> note in this page, is hand-written and safe to edit.
>
> Facts read from the registry (intrinsic transport, interval, lag, license gate, beta/high-volume
> flag, scopes, blob container and cursor key) are **not** editable here — fix the collector, then
> regenerate. The *Collects*,
> *Graph endpoint(s)*, and license-nuance columns are hand-written prose in
> `internal/collectordoc/annotations.go`, because they describe things the registry cannot see (what
> a collector is for, which endpoints it polls). The *Metric namespace* / *Log event* columns are
> **also generated** — from each collector package's committed `testdata/signals.json` golden
> ([#140](https://github.com/rknightion/graph2otel/issues/140)), not hand-written: that golden is a
> real capture of what the package's tests drove into an in-memory Recorder, so it cannot describe a
> signal that does not exist. Metric names show their attribute keys (`name{key1,key2}`); log event
> names appear bare — a log's attribute set runs 15–22 keys deep and is per-entity by design (see the
> cardinality rule below), so listing them here would be noise, not signal.

**Columns:**

- **Intrinsic transport** — the registry candidate's source transport. The collector availability
  snapshot resolves one `collector.transport` per tenant/logical collector after configuration and
  capability checks; it can select a transport fallback or cover one candidate with another.
- **License / beta / volume** — `needs-license/<capability>` where the collector declares
  `license.CapabilityRequirer` and the composition root skips it entirely on an insufficient tier;
  `beta` where it implements `collectors.Experimental` (opt-in, off by default, backed by a Graph
  `beta`-only endpoint with no v1.0 equivalent); and `high-volume opt-in` where it implements
  `collectors.HighVolume` (also explicit opt-in, but for traffic-scaled volume rather than a beta
  API). The capability is printed exactly as it appears in the skip log (`requires entra_p2`). A
  collector can carry more than one of these facts. Parenthesised text is
  hand-written nuance — most importantly for collectors that **partially** degrade: those check the
  tenant's capabilities inside `Collect()` rather than declaring a whole-collector requirement, so
  no interface reports it and the registry cannot know.
- **Interval** — the collector's `DefaultInterval()`; overridable per-collector (globally or
  per-tenant) via the `collectors:` config block.
- **Lag** — a window collector's `Lag()`: the trailing safety margin. The scheduler queries up to
  `now - Lag`, never to `now`, so records still arriving are not missed.
- **Namespace** — the `graph2otel.*` self-observability metrics (scrape duration/success/errors/
  staleness, `build_info`, series cardinality, export job stats, tenant license tier) apply to every
  collector uniformly and aren't repeated per row.

<!-- BEGIN GENERATED COLLECTOR REFERENCE (scripts/regen-generated.sh collectordoc) -->

## Collector availability

Every configured tenant has one current availability point for each logical collector: `graph2otel.collector.availability` (Prometheus-normalized `graph2otel_collector_availability`), a value-1 gauge with `tenant_id`, `collector`, `collector.transport`, `state`, and `reason`. `collector.transport` is the resolved transport selected for that tenant; the reference tables below show each registration candidate's intrinsic transport. This is deliberately distinct from log-only `ingest_transport`, which describes the transport that produced an individual record and is never used for availability.

The admin status JSON adds bounded `limitations` (the omitted sub-signals) and `missing_capabilities` (the exact entitlements that would restore them or a whole blocked collector). These details stay out of metric labels.

`healthy_empty` means the source answered successfully with zero rows; it proves a successful zero-row source response, not that graph2otel observed data. `limited` / `partial_license` means the configured collector is running with a documented capability subset and is non-alerting. `disabled` and `covered` are intentional absence; `degraded`, `failed`, and `startup_failed` are failure states.

| State | Reason |
| --- | --- |
| `disabled` | `transport_not_configured` |
| `disabled` | `experimental_not_enabled` |
| `disabled` | `high_volume_not_enabled` |
| `disabled` | `disabled_by_config` |
| `blocked` | `permission_denied` |
| `blocked` | `license_unavailable` |
| `covered` | `covered_by_alternative` |
| `starting` | `no_completed_run` |
| `starting` | `transport_fallback` |
| `healthy_empty` | `transport_fallback` |
| `healthy_empty` | `empty` |
| `healthy` | `transport_fallback` |
| `healthy` | `success` |
| `limited` | `partial_license` |
| `degraded` | `permission_denied` |
| `degraded` | `source_error` |
| `degraded` | `decode_error` |
| `degraded` | `mapping_error` |
| `degraded` | `missing_event_time` |
| `degraded` | `accounting_mismatch` |
| `degraded` | `timeout` |
| `degraded` | `panic` |
| `failed` | `source_error` |
| `failed` | `decode_error` |
| `failed` | `mapping_error` |
| `failed` | `missing_event_time` |
| `failed` | `accounting_mismatch` |
| `failed` | `timeout` |
| `failed` | `panic` |
| `startup_failed` | `license_detection_failed` |
| `startup_failed` | `transport_initialization_failed` |
| `startup_failed` | `invalid_transport_configuration` |
| `startup_failed` | `credential_initialization_failed` |
| `startup_failed` | `graph_client_initialization_failed` |

## Entra ID — metrics (snapshot collectors)

| Collector | Collects | Intrinsic transport | Graph endpoint(s) | Required scope(s) | License / beta / volume | Interval | Metric namespace |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `entra.access_reviews` | Entra ID Governance access-review DEFINITION inventory (#260) — a review that is created and then never completed is a governance control that looks present and does nothing. Bounded gauge counts definitions by `status`; one log twin per definition carries display name, descriptions, timestamps, the polymorphic scope read by its `@odata.type` (principal + resource queries, so a "review the Global Administrators" review names the roleDefinition it targets), reviewer identities and counts, cadence, and the governance settings, and Warns when neither notifications nor reminders are enabled. Since #319 it ALSO answers whether reviews are being completed: gauges .instances{status} and .decisions{decision, applied_result}, plus `entra.access_review_instance` and `entra.access_review_decision` twins. `decision` and `applied_result` are separate labels on purpose — a decision can be Deny and still never have been applied, which is exactly the "the review ran and changed nothing" case; `recommendation` rides the twin rather than tripling the series. Warns when an instance is past its endDateTime and a decision is still NotReviewed. THE CORRECTION THAT UNBLOCKED IT: #260 recorded that the inline `instances` array is EMPTY under `$expand` on both v1.0 and beta, and read that as "instances are unavailable" — the measurement was right and the conclusion was WRONG. The measurement was about `$expand`, not about the data: the child routes serve it directly, at 0.2s per request (live 2026-07-28). Fan-out is 1+N+M with per-layer caps that mark `arrays_truncated` when they bite, and a failed child fetch omits its parent from the gauge rather than reporting a confident zero (#240). THREE SENTINELS on one decision record, spelled three different ways: `reviewedBy`/`appliedBy` arrive as a fully-populated object whose id is the ZERO GUID with empty-string names (dropped, or a `count by (reviewed_by_id)` shows a phantom reviewer holding every pending decision on the tenant), `reviewedDateTime`/`appliedDateTime` are JSON null, and `principal.lastUserSignInDateTime` is an EMPTY STRING. `contactedReviewers` is reachable and deliberately NOT fetched — no metric or twin was specified for it, and fetching data with nowhere to put it is wasted request budget. `status`, `decision` and `applied_result` are all left unwatched by `wirecheck`: one observed value each, and #234 forbids declaring an enum from documentation | `graph` | `/identityGovernance/accessReviews/definitions` (v1.0 — the endpoint also answers on beta, so no `Experimental` gate; beta additionally returns `backupReviewers`/`customData`/`customDataProvider`, which are therefore not mapped), then `…/definitions/{id}/instances` and `…/instances/{id}/decisions` per parent | `AccessReview.Read.All` | No declared capability, on purpose: #251 predicted an Entra ID Governance license would be required for any data to exist and the live tenant returned a review anyway, so gating would skip it where it demonstrably works. A tenant that genuinely cannot use the endpoint 403s, which is a graceful info-skip | 1h | `entra.access_reviews.decisions{applied_result,decision}`, `entra.access_reviews.instances{status}`, `entra.access_reviews.total{status}`, `graph2otel.api.unexpected{collector,field,kind}`, plus a log twin per `entra.access_review`, `entra.access_review_decision`, `entra.access_review_instance` |
| `entra.agent_governance` | Agent ID blueprint and agent-identity governance (#333) — the blueprint-to-agent lineage, and the two gaps that matter operationally: an agent with NO sponsor and an agent with NO owner, neither of which the generic application/service-principal collectors or the risky-agent streams model. Bounded gauges: entra.agent_governance.blueprints.total, .blueprint_principals{account_enabled, sign_in_audience}, .agents{account_enabled, has_sponsor, has_owner, app_role_assignment_required} — each dimension is true/false/unknown rather than a boolean, because a gauge point cannot omit a labeling dimension the way a log twin omits an attribute: an absent wire field or a failed sponsors/owners fetch must read as `unknown`, never as `false`, or a fetch failure would publish itself as a governance gap. Bounded at 81 series by construction, and every live row carries true or false. Plus .agents.without_sponsor and .agents.without_owner as their own alertable counts. Log twins entra.agent_blueprint, entra.agent_blueprint_principal and entra.agent_identity, the last carrying blueprint_id as the lineage join key. The expanded sponsors/owners arrive as FULL directoryObject payloads (business phone, job title, employee fields); only @odata.type, id and displayName are decoded, so a sponsor's directory record is never shipped sight-unseen under an agent-governance collector. Live-measured 2026-07-28: 1 blueprint, 2 blueprint principals, 3 agents, one of which has zero owners | `graph` | `/applications/microsoft.graph.agentIdentityBlueprint`, `/servicePrincipals/microsoft.graph.agentIdentityBlueprintPrincipal`, `/servicePrincipals/microsoft.graph.agentIdentity?$expand=sponsors` and the same collection with `?$expand=owners` — all v1.0. There is NO `/directory/agentIdentityBlueprints` entity set: the documented paths answer 400 `Resource not found for the segment` on both v1.0 and beta, because these are DERIVED TYPES of servicePrincipal/application reached by OData type cast. `$expand` accepts ONE property per request (`?$expand=sponsors,owners` answers 400 "Only one property can be expanded in a single query"), which is why sponsors and owners are two requests joined on the agent id — four fixed requests, never a per-agent fan-out (live-measured 2026-07-28, #333) | `Application.Read.All` | runs on every tier; read-only `Application.Read.All`, already held. NOT Experimental — all three routes are v1.0 GA and Experimental is reserved for genuine Graph beta (#183); the issue's original "place agent enumeration behind Experimental" assumed a beta-only surface and the measurement supersedes it. Each fetch degrades independently, and a failed sponsors fetch withholds has_sponsor rather than reporting a false governance gap. sign_in_audience is left UNWATCHED by wirecheck: two values on two rows from one tenant is not a value set | 15m | `entra.agent_governance.agents{account_enabled,app_role_assignment_required,has_owner,has_sponsor}`, `entra.agent_governance.agents.without_owner`, `entra.agent_governance.agents.without_sponsor`, `entra.agent_governance.blueprint_principals{account_enabled,sign_in_audience}`, `entra.agent_governance.blueprints.total`, plus a log twin per `entra.agent_blueprint`, `entra.agent_blueprint_principal`, `entra.agent_identity` |
| `entra.agreements` | Terms of Use agreements + acceptance state | `graph` | `/agreements`, `/agreements/{id}/acceptances` | `Agreement.Read.All`, `AgreementAcceptance.Read.All` | `needs-license/entra_p1` | 15m | `entra.agreements.acceptances.total{agreement,state}`, `entra.agreements.total` |
| `entra.app_ownership` | Application/service-principal ownership and federated identity credentials (#244/#316). Bounded gauges: entra.application.ownership{has_owner, sign_in_audience}, entra.service_principal.ownership{has_owner, service_principal_type} (21 of 27 apps ownerless on m7kni — an ownerless app is one nobody can be asked "is this still needed?"), and entra.application.federated_credentials{issuer_host}. State twins: entra.application and entra.service_principal (Warn on zero owners), plus entra.federated_identity_credential (issuer/subject — the trust edge entra.credential_expiry cannot see, because a federated credential has no expiry; always Warn). Service-principal identity/type fields and zero-owner state are live-measured (2026-07-23, #244); owner-UPN detail and the FIC record shape remain synthetic because m7kni has neither an owned object nor a FIC | `graph` | `/applications?$expand=owners`, `/servicePrincipals?$expand=owners` (v1.0), `/beta/applications?$expand=federatedIdentityCredentials` | `Application.Read.All` | runs on every tier; beta only for the FIC $expand (Graph allows one $expand per query, so FIC is a separate fetch from owners), not marked Experimental so the v1.0 ownership signal is not hidden. No license requirement | 6h | `entra.application.federated_credentials{issuer_host}`, `entra.application.ownership{has_owner,sign_in_audience}`, `entra.service_principal.ownership{has_owner,service_principal_type}`, plus a log twin per `entra.application`, `entra.federated_identity_credential`, `entra.service_principal` |
| `entra.auth_methods_policy` | Tenant-wide authentication methods policy. Two bounded gauges (per-method enabled state, and a legacy-method count covering SMS and voice) plus, since #317, the configuration detail those gauges necessarily discard: one `entra.auth_methods_policy` twin for the singleton (policy version, migration state, and the registration campaign's state, snooze duration and bounded include/exclude target ids) and one `entra.auth_method_configuration` twin per returned configuration, carrying that subtype's own observed fields — FIDO2 attestation and key restrictions, Authenticator's software-OATH flag, Voice's office-phone flag, QR-code PIN length and lifetime. Attributes are a FIXED typed schema and arrays are capped, never raw configuration JSON. A subtype nobody has captured emits ONLY id, state and @odata.type: an unreviewed field on an unseen provider could be sensitive, so a new subtype deliberately arrives under-described rather than dumped verbatim. Nested multi-field sub-schemas (X.509's authentication-mode/user-binding/CRL config, Authenticator's featureSettings) are NOT modeled yet — deferred with a stated reason on #317, not overlooked | `graph` | `/policies/authenticationMethodsPolicy` | `Policy.Read.AuthenticationMethod` | — | 15m | `entra.auth_methods_policy.legacy_enabled.total`, `entra.auth_methods_policy.method.enabled{method}`, plus a log twin per `entra.auth_method_configuration`, `entra.auth_methods_policy` |
| `entra.auth_strength` | Authentication-strength policy inventory (#322) — which built-in and custom strength policies exist and which method combinations satisfy each, the posture Conditional Access aggregates reference by id but never enumerate. Bounded gauge entra.auth_strength.policies{policy_type, requirements_satisfied}; one entra.auth_strength_policy log twin per policy carrying the allowedCombinations list (capped at 100) and the true uncapped count. The comma-joined combination strings Graph returns (`password,microsoftAuthenticatorPush`) are emitted VERBATIM and never split: each is one combination meaning both methods together, and splitting a conjunction into two independent methods would silently change what the policy says. Policy ids and names are log-only. Live-measured 2026-07-28: 3 policies, all policyType=builtIn / requirementsSatisfied=mfa, largest allowedCombinations 19 entries, combinationConfigurations empty on all three | `graph` | `/policies/authenticationStrengthPolicies` (v1.0) | `Policy.Read.All` | runs on every tier (v1.0, not beta); read-only `Policy.Read.All`, already held. combinationConfigurations emits a COUNT ONLY — empty on every live policy, so no per-entry mapper is written against unseen data; the unblock is a tenant with a configured combination configuration (e.g. a FIDO2 AAGUID restriction). policy_type and requirements_satisfied are left UNWATCHED by wirecheck: exactly one value of each is observed, and one observed value is not a value set | 15m | `entra.auth_strength.policies{policy_type,requirements_satisfied}`, plus a log twin per `entra.auth_strength_policy` |
| `entra.conditional_access` | Conditional Access posture. Two bounded aggregate gauges (policy count by enforcement state, named-location count by type and trust) plus, since #318, one state twin per entity so the posture can be read rather than only counted: `entra.conditional_access_policy` carries the policy's id, display name, template id, state, timestamps, bounded include/exclude user/group/role/location arrays, client-app types, risk levels, whether grant and session controls are present at all, the grant operator, built-in controls and any authentication-strength name; `entra.named_location` carries the location's id, display name, type, timestamps, and either its IPv4/IPv6 CIDR ranges (kept as two separate arrays so the @odata.type discriminator survives) or its country list and lookup method. NO per-condition or per-target child records — one twin per entity, and every array is capped with `arrays_truncated` set when it was. The trap this collector is written around: a countryNamedLocation OMITS `isTrusted` while an ipNamedLocation carries it, so the twin emits the attribute only when the key was on the wire — an absent key is never rendered as false, because "not trusted" and "trust is not a concept for this location type" are different facts. An entity whose state or @odata.type is unrecognized is still emitted as a twin (only the gauge bucket skips it), so a future enum member is never silently dropped | `graph` | `/identity/conditionalAccess/policies`, `/identity/conditionalAccess/namedLocations` | `Policy.Read.All` | `needs-license/entra_p1` | 15m | `entra.ca.policies.total{state}`, `entra.named_locations.total{is_trusted,type}`, `graph2otel.api.unexpected{collector,field,kind}`, plus a log twin per `entra.conditional_access_policy`, `entra.named_location` |
| `entra.consent` | OAuth2 permission grants + app-role assignment consent surface | `graph` | `/oauth2PermissionGrants`, app role assignments | `Directory.Read.All`, `Application.Read.All` | — | 15m | `entra.consent.grants.total{consent_type,privilege}`, plus a log twin per `entra.consent_grant` |
| `entra.credential_expiry` | App + service principal credential (secret/certificate) expiry buckets | `graph` | `/applications`, `/servicePrincipals` (`$select=keyCredentials,passwordCredentials`) | `Application.Read.All` | — | 15m | `entra.credentials.expiring.total{credential_type,expiry_bucket,owner_type}`, plus a log twin per `entra.app_credential` |
| `entra.cross_tenant_access` | Entra cross-tenant access posture (#321) — inbound/outbound B2B collaboration, B2B direct connect, tenant restrictions, inbound trust and automatic user consent: the boundaries deciding which other tenants' principals can reach this one. Distinct control plane from Global Secure Access `/networkAccess/settings/crossTenantAccess`, which `entra.gsa` covers. Bounded gauges: entra.cross_tenant_access.access{service, direction, target_kind, access_type}, .inbound_trust{setting}, .automatic_user_consent{direction}, .partners.total. Log twins entra.cross_tenant_access_policy (root singleton) and entra.cross_tenant_access_default. The load-bearing rule is that ABSENT CONFIGURATION NEVER BECOMES false OR blocked: every optional bool is a pointer and an absent block emits no point, because fabricating a deny would tell an operator the tenant is locked down when it is not. Live-measured 2026-07-28: 0 partners, and tenantRestrictions.devices is null on the wire | `graph` | `/policies/crossTenantAccessPolicy`, `/policies/crossTenantAccessPolicy/default`, `/policies/crossTenantAccessPolicy/partners` (v1.0) | `Policy.Read.All` | runs on every tier (v1.0, not beta); read-only `Policy.Read.All`, already held. Each of the three fetches degrades to a non-fatal error independently. Partners emit a COUNT ONLY — m7kni has none, so no per-partner field mapper is written against unseen data (same evidence gate as entra.tenant_policy's scoped-policy collections); the unblock is a tenant with a real partner configuration. access_type is left UNWATCHED by wirecheck: one tenant's configuration is not a value set | 15m | `entra.cross_tenant_access.access{access_type,direction,service,target_kind}`, `entra.cross_tenant_access.automatic_user_consent{direction}`, `entra.cross_tenant_access.inbound_trust{setting}`, `entra.cross_tenant_access.partners.total`, plus a log twin per `entra.cross_tenant_access_default`, `entra.cross_tenant_access_policy` |
| `entra.deleted_items` | Recycle-bin census: recoverable soft-deleted directory objects by type + near-purge state, log twin per object | `graph` | `/directory/deletedItems/microsoft.graph.{user,group,application,servicePrincipal,device}` | `Directory.Read.All` | — | 30m | `entra.deleted_items.count{near_purge,object_type}`, plus a log twin per `entra.deleted_item` |
| `entra.devices` | Directory device inventory: trust type, compliance, managed state, OS, staleness | `graph` | `/devices`, `/devices/$count` | `Device.Read.All` | — | 15m | `entra.devices.compliance.total{is_compliant}`, `entra.devices.managed.total{is_managed}`, `entra.devices.os.total{operating_system}`, `entra.devices.stale.total{threshold_days}`, `entra.devices.total{trust_type}` |
| `entra.directory_counts` | Tenant-wide directory object counts by type | `graph` | `/{type}/$count` per object type | `Directory.Read.All` | — | 5m | `entra.directory.objects.total{type}` |
| `entra.directory_recovery` | Entra Backup and Recovery posture (#334) — whether the directory is actually RECOVERABLE right now, which no other collector here can answer: every other Entra collector reports what the directory contains, this one reports whether a recent enough snapshot exists to put it back. Bounded gauges: entra.directory_recovery.snapshots (a total, always emitted including a measured 0), .newest_snapshot_age in seconds (the real posture signal — recoverability degrades with staleness and a count alone cannot express that; emitted ONLY when a snapshot with a parseable createdDateTime exists, because a 0-second age would claim a snapshot had just been taken on a tenant that has none), .jobs (a total) and .jobs_by_status{status}. The job gauge is split in two because the jobs collection is EMPTY in the healthy steady state — a recovery job only exists once someone runs a restore — so without a separate total, 'no restore has ever run' and 'the collector is disabled' would be the same absent series. One entra.directory_recovery_snapshot twin per snapshot and one entra.directory_recovery_job twin per job. Both twins are STATE twins stamped at poll time, never backdated to createdDateTime: the oldest retained snapshot sits exactly at the retention edge and backdating would push it against the backend's 7-day accept horizon every cycle. Live-measured 2026-07-28: 7 snapshots taken daily at 22:00:00Z with no drift, spanning 2026-07-21 to 2026-07-27, and 0 jobs. Retention is therefore 7 days — Microsoft's Graph overview page says 5 and its admin-center page says 7, so this is measured, not inferred. snapshot.totalChangedObjects is DECLARED IN THE EDM AND NEVER SENT: absent from all 7 rows, still absent when named in $select and under $expand, so every numeric on both resources is a pointer emitted only when present — a bare int would publish a fabricated 0 meaning 'nothing changed since the last snapshot', or on a job a fabricated clean restore | `graph` | `/directory/recovery/snapshots` and `/directory/recovery/jobs` (v1.0). Exactly two plain GETs, one per collection, and no other URL is ever constructed: the EDM binds a `cancel` action plus `getChanges`/`getFailedChanges` to the job types and recoveryAction includes restore, none of which may appear in runtime code. A snapshot is also not individually addressable — GET .../snapshots/{id} answers 404 for an id the collection returned a second earlier, with the trailing `==` both raw and percent-encoded (live-measured 2026-07-28, #334) | `EntraBackup.Read.All` | runs on every tier. v1.0, NOT beta and NOT Experimental: the surface returns identical data on both versions and the entraRecoveryServices schema block is byte-identical in both $metadata documents, so per #183 Experimental (reserved for genuine Graph beta) would mis-signal maturity to an operator. Read-only `EntraBackup.Read.All`, granted to graph2otel-poller 2026-07-28. Wirecheck watches the job `status` label, derived from the bucket map the collector keys on; its members come from the EDM's recoveryStatus enum rather than from observed rows, since the jobs collection is empty here — a declared Microsoft enum is a stronger basis than the single-observed-value case this project refuses to watch on. Nothing on the snapshot resource is watched: it carries no enum, only an id and a timestamp | 1h | `entra.directory_recovery.jobs`, `entra.directory_recovery.jobs_by_status{status}`, `entra.directory_recovery.newest_snapshot_age`, `entra.directory_recovery.snapshots`, `graph2otel.api.unexpected{collector,field,kind}`, plus a log twin per `entra.directory_recovery_job`, `entra.directory_recovery_snapshot` |
| `entra.domains` | Domain verification/authentication posture | `graph` | `/domains` | `Domain.Read.All` | — | 15m | `entra.domains.federated.total`, `entra.domains.total{authentication_type,is_verified}`, plus a log twin per `entra.domain` |
| `entra.groups` | Group population by type/membership/security/mail-enabled, role-assignable count | `graph` | `/groups/$count` (filtered) | `Group.Read.All` | — | 5m | `entra.groups.role_assignable.total`, `entra.groups.total{group_type,mail_enabled,membership_type,security_enabled}` |
| `entra.gsa` | Global Secure Access posture (#239) — the #130 'until a GSA tenant exists' deferral fired; m7kni reports onboarded. Bounded gauges: entra.gsa.onboarding_status (numeric enum 0=onboarded/1=in-progress/2=error-or-offboarded/-1=unmapped, see docs/signals.md), entra.gsa.forwarding_profiles{traffic_forwarding_type, state}, entra.gsa.filtering_policies{action}, entra.gsa.remote_networks, and 0/1 flags entra.gsa.signaling_enabled + entra.gsa.packet_tagging_enabled. Log twins entra.gsa_forwarding_profile (priority, client-fallback, association count) and entra.gsa_filtering_policy. The per-remote-network twin is not emitted — m7kni has none, so there is no live shape to map. Traffic logs are a separate data-blocked piece: graph2otel-poller already holds NetworkAccess.Read.All and the endpoint returns 200/empty while every forwarding profile is disabled, so no mapper is written without a live row | `graph` | `/beta/networkAccess/tenantStatus`, `/beta/networkAccess/forwardingProfiles`, `/beta/networkAccess/filteringPolicies`, `/beta/networkAccess/settings/conditionalAccess`, `/beta/networkAccess/settings/crossTenantAccess`, `/beta/networkAccess/connectivity/remoteNetworks` | `Policy.Read.All` | `beta` (beta-only (networkAccess exists on no v1.0 surface), so marked Experimental — an operator opts in explicitly. Needs Policy.Read.All, already in the poller's token; each of the six fetches degrades to a non-fatal error independently) | 6h | `entra.gsa.filtering_policies{action}`, `entra.gsa.forwarding_profiles{state,traffic_forwarding_type}`, `entra.gsa.onboarding_status`, `entra.gsa.packet_tagging_enabled`, `entra.gsa.remote_networks`, `entra.gsa.signaling_enabled`, plus a log twin per `entra.gsa_filtering_policy`, `entra.gsa_forwarding_profile` |
| `entra.licensing` | SKU consumption + prepaid/enabled units | `graph` | `/subscribedSkus` | `LicenseAssignment.Read.All`, `Group.Read.All` | — | 15m | `entra.license.capability_status{sku,status}`, `entra.license.consumed{sku}`, `entra.license.enabled{sku}`, `entra.license.groups_with_errors.total`, `entra.license.units{sku,state}`, plus a log twin per `entra.license_group_error` |
| `entra.mfa_registration` | MFA/SSPR/passwordless registration + capability status, per-method counts, admin MFA-capable split | `graph` | `/reports/authenticationMethods/userRegistrationDetails` | `AuditLog.Read.All` | `needs-license/entra_p1` | 1h | `entra.mfa.registration.admin_mfa_capable.total{is_admin}`, `entra.mfa.registration.methods.total{method}`, `entra.mfa.registration.users.total{status,user_type}`, plus a log twin per `entra.user_registration` |
| `entra.organization` | Tenant posture: on-prem sync state/age, tenant age, verified domain count, tenant type | `graph` | `/organization` | `Organization.Read.All` | — | 15m | `entra.directory.sync.last_sync_age_seconds`, `entra.organization.age_days`, `entra.organization.info{tenant_type}`, `entra.organization.on_premises_sync_enabled`, `entra.organization.verified_domains.total` |
| `entra.pim_alerts` | Microsoft's OWN pre-computed privileged-access findings (#256) — stale accounts in privileged roles, roles assigned outside PIM, roles activatable without MFA, too many global admins, PIM licensing — each shipped with Microsoft's severity, security impact, mitigation steps and prevention guidance, which graph2otel re-derives nowhere else. Three bounded gauges: entra.pim.alert.count{alert_type, severity, is_active}, entra.pim.alert.incidents{alert_type, severity} (how many entities each finding covers) and entra.pim.alert.configurations{alert_type, is_enabled} — a switched-OFF alert reports itself inactive forever and is otherwise indistinguishable from a healthy one, so is_enabled=false is itself a finding. One entra.pim_alert log twin per alert carries the raw id, Microsoft's remediation prose and the per-type thresholds, and Errors on an active high-severity finding. The alert-type label is the STRIPPED type suffix (`StaleSignInAlert`): the raw id embeds the tenant GUID. The per-incident entities are NOT reachable — `alertIncidents` 400s even with the mandatory filter — so incidentCount is the finest granularity offered, and `lastModifiedDateTime` is the .NET zero date on an alert that has never fired and is dropped rather than emitted | `graph` | `/beta/identityGovernance/roleManagementAlerts/{alerts,alertDefinitions,alertConfigurations}?$filter=scopeId eq '/' and scopeType eq 'DirectoryRole'` — the filter is MANDATORY on every segment (a bare list answers `400 MissingProvider`, which reads like the endpoint not existing; see docs/graph-api-gotchas.md) | `RoleManagementAlert.Read.Directory` | `beta` (read-only `RoleManagementAlert.Read.Directory`; beta-only (v1.0 has no roleManagementAlerts segment), so Experimental and opt-in via explicit enable. Runs on every tier — the endpoint answered 200 with 7 rows on a tenant WITHOUT Entra ID P2, one of them being Microsoft's own "you don't have P2" alert; a 403 or a MissingProvider 400 is a graceful skip) | 6h | `entra.pim.alert.configurations{alert_type,is_enabled}`, `entra.pim.alert.count{alert_type,is_active,severity}`, `entra.pim.alert.incidents{alert_type,severity}`, plus a log twin per `entra.pim_alert` |
| `entra.pim_role_policies` | PIM role-activation policy requirements (#242) — what it takes to activate a role, the class of misconfiguration entra.roles cannot see. Bounded gauge counts policies by (requirement ∈ mfa_on_activation/approval_required/justification_required/auth_context_required/activation_expiry_required/eligibility_expiry_required, enabled, caller ∈ end_user/admin); one log twin per policy (role GUID joined from the policy assignments, enabled-rule list, approval + durations) that Warns when activation needs neither MFA nor approval | `graph` | `/policies/roleManagementPolicies?$expand=rules`, `/policies/roleManagementPolicyAssignments` (directory scope) | `Policy.Read.All`, `RoleManagement.Read.Directory` | Entra ID P2 (PIM) — the PIM half of `entra.roles` is already live on this tenant. Runs on every tier; a 403 where PIM is absent is a graceful skip. The role display name is not resolved (GUID join key only) | 6h | `entra.pim.role_policy.requirement{caller,enabled,requirement}`, plus a log twin per `entra.pim_role_policy` |
| `entra.privileged_groups` | Member counts for a SMALL SET OF GROUPS THE OPERATOR NAMES BY ID (#337, the surviving residual of #43). This is the one place in the project where a group_id reaches a metric label, and the exception is safe for exactly one reason: the label set is a config allowlist capped at config.MaxPrivilegedGroupAllowlist entries and validated at load, so cardinality is decided by a human editing YAML and can never grow with tenant size at runtime. Two gauges, deliberately: entra.privileged_groups.members.total{group_id} carries the count and is EMITTED ONLY for a group read successfully this cycle, so a 0 there is a measured empty group; entra.privileged_groups.accessible{group_id} is emitted for EVERY configured group EVERY cycle, 1 or 0, so "graph2otel could not look" is always stated rather than inferred from a missing series. Without the second gauge a deleted or forbidden group would be indistinguishable from an empty one, which are opposite facts about a privileged group. The log twin (entra.privileged_group) carries the bounded failure reason a gauge cannot express (not_found / forbidden / error) and Warns. It counts TRANSITIVE members (`/groups/{id}/transitiveMembers/$count`), so the number is a TOTAL rather than a floor: a principal nested inside another group is a full member for every authorization decision Entra makes, and counting only direct members would make this signal read LOW exactly when someone has widened the blast radius by nesting. Both shapes were live-verified to work identically and transitive was chosen on #337 — an over-count is a question, an under-count is a missed escalation. It counts every transitive member TYPE (users, nested groups and service principals), not users only. Empty allowlist (the default) registers the collector and no-ops | `graph` | `/groups/{id}/members/$count`, one request per allowlisted group per cycle. The `$count` segment needs BOTH `ConsistencyLevel: eventual` AND `Accept: text/plain`: it answers a bare integer as text/plain and returns HTTP 415 — not 400, not 406 — when the request asks for application/json, and 400 Request_UnsupportedQuery with no ConsistencyLevel (live-measured 2026-07-27, #337). The shared collectors.Count helper sends both. A deleted group answers 404 Request_ResourceNotFound and is reported inaccessible, never as zero members | `Group.Read.All` | Opt-in per tenant via `tenants[].privileged_groups.group_ids`; no new scope beyond the `Group.Read.All` the tenant already grants | 5m | `entra.privileged_groups.accessible{group_id}`, `entra.privileged_groups.members.total{group_id}`, plus a log twin per `entra.privileged_group` |
| `entra.recommendations` | Entra recommendations catalog (status, priority) | `graph` | `/directory/recommendations` (beta) | `DirectoryRecommendations.Read.All` | `beta` | 30m | `entra.recommendations.impacted_resources.total{recommendation}`, `entra.recommendations.total{priority,status}`, `graph2otel.api.unexpected{collector,field,kind}`, plus a log twin per `entra.recommendation` |
| `entra.related_tenants` | Entra Tenant Governance related-tenant discovery (#336) — which OTHER tenants this one is actually entangled with, as DISCOVERED by Microsoft rather than declared by an administrator. entra.cross_tenant_access reports the cross-tenant policy an admin configured; this reports the relationships that exist whether or not anyone configured them (a partner whose users sign in here, a multi-tenant application of theirs in use here, guests registered in either direction). Bounded gauges: entra.related_tenants.discovery_enabled and .can_receive_invitations (0/1 posture flags, always emitted), .total{is_microsoft_infrastructure} — the split is the whole point, since 3 of 16 live rows are Microsoft's own infrastructure tenants and an unsplit total would report 16 external relationships where there are 13 — .with_metrics{kind} (how many discovered tenants Microsoft actually supplied each metric block for, bounded at 5 series), and the cross-row sums .b2b_registered_users{direction}, .b2b_signin_users{direction}, .multi_tenant_applications{direction}. One entra.related_tenant twin per discovered tenant carrying that tenant's id and its per-relationship counters. The tenant id is on `related_tenant_id`, deliberately NOT the shared `tenant_id` key, which telemetry.WithTenant stamps with THIS tenant (#143) — naming the other party's id tenant_id would make a partner's records look like a different graph2otel deployment. Live-measured 2026-07-29: 16 related tenants, 13 external and 3 Microsoft infrastructure. Of the five metric blocks a row can carry, multiTenantApplicationMetrics is populated on all 16, the three B2B blocks on exactly ONE, and billingMetrics on NONE — so absence is the NORMAL reading here and every counter is a pointer emitted only when the wire sends it; a bare int would publish a fabricated 0 meaning 'no inbound B2B users from this partner' on fifteen of sixteen rows. The three blocks also do not share a field set (b2BRegistrationMetrics carries lifetime user totals, the two sign-in blocks carry monthly user AND application counters, multiTenantApplicationMetrics carries application counters only) while one leaf struct decodes all of them, which is exactly why the pointers matter. billingMetrics' leaves are deliberately UNMODELED — null on every live row, so there is no observed shape to map (#142/#165) — and the twin carries has_billing_metrics so the day one appears is visible. The twins are STATE twins stamped at poll time, never backdated: all 16 rows carry a createdDateTime within one second of each other because it dates the moment discovery was ENABLED, not the relationship, so backdating would date every relationship to that instant and re-stamp them all on a re-enable | `graph` | `/directory/tenantGovernance/settings` and `/directory/tenantGovernance/relatedTenants` (beta). The documented path `/beta/tenantRelationships/tenantGovernance` does NOT exist — 400 `Resource not found for the segment`; the tenantGovernance navigation property hangs off `directory`, found by grepping the beta $metadata EDM for the type name, the same route that corrected #333's documented entity set (live-measured 2026-07-28, #336) | `TenantGovernance-Setting.Read.All`, `TenantGovernance-RelatedTenant.Read.All` | `beta` (runs on every tier, and Experimental: both paths exist only on Graph beta, so per #183 this is a genuine beta surface and an operator opts in explicitly. Read-only `TenantGovernance-Setting.Read.All` + `TenantGovernance-RelatedTenant.Read.All`, both granted to graph2otel-poller 2026-07-28. Discovery is a tenant feature an administrator must enable and Microsoft states the enable is IRREVERSIBLE — so `settings` is read FIRST and the relatedTenants fetch is skipped when isRelatedTenantsEnabled is false, meaning the collector never has to eat a 403 to find out. On that skip the count gauges emit NO POINTS rather than a zero: 'discovery is off' and 'discovery is on and found nothing' are opposite facts and a 0 would assert the second, with discovery_enabled carrying which one it was. The two 403s here are textually distinct and never collapse into one bucket (`Related tenant discovery has not been enabled for tenant <id>` vs `Insufficient permissions to perform this operation.`). Nothing is watched by wirecheck, recorded as a decision rather than an omission: this surface carries no enum at all — two booleans, a tenant id, timestamps and integer counters — so there is no value set for a watchdog to guard (#233/#234)) | 6h | `entra.related_tenants.b2b_registered_users{direction}`, `entra.related_tenants.b2b_signin_users{direction}`, `entra.related_tenants.can_receive_invitations`, `entra.related_tenants.discovery_enabled`, `entra.related_tenants.multi_tenant_applications{direction}`, `entra.related_tenants.total{is_microsoft_infrastructure}`, `entra.related_tenants.with_metrics{kind}`, plus a log twin per `entra.related_tenant` |
| `entra.risk` | Current risky-users and risky-service-principals counts, with a log twin per risky entity. The risky-users gauge is reconciled against the directory's deleted-items tombstones so a deleted-but-once-risky user is not counted forever (#155); the twin keeps the entity, marked with a reliable `is_deleted` | `graph` | `/identityProtection/riskyUsers`, `/identityProtection/riskyServicePrincipals`, `/directory/deletedItems/microsoft.graph.user` | `IdentityRiskyUser.Read.All`, `IdentityRiskyServicePrincipal.Read.All`, `User.Read.All` | `partial-license/risky_users:entra_p2` (risky users are omitted without Entra ID P2; risky service principals are attempted unconditionally because capability detection is a live-measured false negative, and endpoint 403 drives runtime permission state) | 15m | `entra.risky_service_principals.total{risk_level,risk_state}`, `entra.risky_users.total{risk_level,risk_state}`, `graph2otel.api.unexpected{collector,field,kind}`, plus a log twin per `entra.risky_service_principal`, `entra.risky_user` |
| `entra.risky_agents` | Current risky Entra Agent ID (AI-agent) identity counts by risk level and state, with a log twin per risky agent (id, agentDisplayName, riskDetail, the enabled/deleted/processing flags). The STATE feed behind `entra.agent_risk_detections`; the agent analog of `entra.risk`. Beta/Experimental, ungated (polls unconditionally; 200/empty or 403 where the feature is absent — a license gate would hide it on tenants where the endpoint works) | `graph` | `/beta/identityProtection/riskyAgents` | `IdentityRiskyAgent.Read.All` | `beta` | 15m | `entra.risky_agents.total{risk_level,risk_state}`, `graph2otel.api.unexpected{collector,field,kind}`, plus a log twin per `entra.risky_agent` |
| `entra.role_definitions` | Directory role DEFINITIONS and the actions they grant (#320) — the half `entra.roles` cannot see, because that collector reports who holds a role and this one reports what a role IS. A custom or disabled definition with no current assignment is invisible to every other collector here and is exactly the thing worth finding. One bounded gauge, entra.role_definitions.definitions{is_built_in, is_enabled} — cardinality is 4 regardless of tenant size, since both labels are booleans this collector derives rather than Graph-supplied values. One entra.role_definition log twin per definition carries the id/templateId join keys, the allowed-action list (flattened across every rolePermissions element, capped at 300) and the TRUE uncapped allowed_action_count. Role ids, names and action strings are log-only: an action string is per-definition detail and 2,222 of them exist across 145 definitions. An unrecognized action is emitted verbatim and is never grounds to drop a definition. Live-measured 2026-07-28: 145 definitions, all built-in, all enabled, 0 custom, templateId == id on every one, max 262 actions on Global Administrator | `graph` | `/roleManagement/directory/roleDefinitions` (v1.0). `$top` is UNSUPPORTED at ANY value — both `$top=100` and `$top=999` answer 400 Request_UnsupportedQuery, so the request sends no page-size hint at all and follows @odata.nextLink only (live-measured 2026-07-28, #320; see docs/graph-api-gotchas.md) | `RoleManagement.Read.Directory` | runs on every tier (v1.0, not beta); read-only `RoleManagement.Read.Directory`, already in the poller's token. Nothing is declared to internal/wirecheck: no metric label is keyed off a Graph-supplied value set | 1h | `entra.role_definitions.definitions{is_built_in,is_enabled}`, plus a log twin per `entra.role_definition` |
| `entra.roles` | Standing directory-role membership; PIM active/eligible/permanent assignment counts | `graph` | `/directoryRoles`, `/roleManagement/directory/roleAssignmentScheduleInstances`, `.../roleEligibilityScheduleInstances` | `RoleManagement.Read.Directory`, `RoleAssignmentSchedule.Read.Directory`, `RoleEligibilitySchedule.Read.Directory` | `partial-license/pim_assignments:entra_p2` (PIM half only needs `entra_p2`, checked inside Collect(): the standing-membership half runs on every tier, and without P2 the PIM assignment counts are skipped rather than zero-emitted) | 10m | `entra.pim.assignments.total{assignment_type,role_name}`, `entra.pim.permanent_assignments.total{role_name}`, `entra.roles.members.total{role_name}`, plus a log twin per `entra.role_member` |
| `entra.secure_score` | Latest secure score with per-control state (score by category, peer-average benchmarks) and the control-profile catalog, plus a log twin per control and per profile carrying the remediation worklist — actionUrl, tier, threats — that the counts collapse away (#243). Microsoft publishes at most daily, hence the hourly poll | `graph` | `/security/secureScores`, `/security/secureScoreControlProfiles` | `SecurityEvents.Read.All` | — | 1h | `entra.secure_score.by_category{category}`, `entra.secure_score.control_profiles.by_category{category}`, `entra.secure_score.control_profiles.by_status{status}`, `entra.secure_score.current`, `entra.secure_score.max`, `entra.secure_score.max_by_category{category}`, `entra.secure_score.peer_average{basis}`, `entra.secure_score.percentage`, `graph2otel.api.unexpected{collector,field,kind}`, plus a log twin per `entra.secure_score_control`, `entra.secure_score_control_profile` |
| `entra.service_activity` | Measured tenant sign-in and Global Secure Access experience from the beta M365 monitoring serviceActivity surface (#368) — the counterpart to m365.service_health, which reports what MICROSOFT has declared. This reports what the tenant actually experienced, at 30-minute resolution, between declared incidents. Four bounded gauges over 19 metric functions, grouped by what each one counts so a sum is meaningful: .signins{activity} (MFA success/failure, SAML success, and the three Conditional Access flavors), .network_access_apps{activity}, .network_access_users{activity} and .network_access_branches{activity} (branch liveness, BGP and tunnel state). 19 series total, every dimension a code-supplied closed set. NO LOG TWIN, and that is not an omission: every function returns a bare collection of {intervalStartDateTime, value} with no entity dimension whatsoever, so nothing per-entity is fetched and #114 has nothing to preserve — this is the one collector shape where a gauge is the complete signal. SCOPE CORRECTION vs the issue's premise: it was filed expecting Teams, Exchange and M365 Apps metrics too, and the beta EDM declares NONE of those on this surface — sign-in health and Global Secure Access are all that exists, which is why this lands in entra.* rather than m365.*. Live-measured 2026-07-28: all 19 functions 200 with 48 buckets over 24h; 18 of them are measured ZERO on m7kni (no MFA-method sign-ins recorded, no GSA deployed) and ca_compliant_devices_signin_success carries real data in 47 of 48 buckets — a measured zero, not an empty response, and the two must not be conflated | `graph` | `/beta/reports/serviceActivity/getMetricsFor*(inclusiveIntervalStartDateTime=…,exclusiveIntervalEndDateTime=…,aggregationIntervalInMinutes=30)` — 19 bound functions, one request each per cycle. `aggregationIntervalInMinutes` accepts ONLY 5, 10, 15 or 30: 60 returns a 400 that names the allowed set (live-measured 2026-07-28), which is a usefully precise error rather than a denial. The window end is truncated down to a bucket boundary so every bucket returned is COMPLETE, and the gauge publishes the latest one — sampling a partial trailing bucket would report a half-counted interval as a drop | `Reports.Read.All` | `beta` (beta-only (serviceActivity exists on no v1.0 surface), so Experimental and opt-in per #183. Readable on scopes the poller already holds — no new grant. Each of the 19 functions degrades independently; a failed one contributes nothing rather than zero. Nothing is declared to internal/wirecheck: the activity names are graph2otel's own rendering of Microsoft's function names, not a Graph-supplied value set) | 30m | `entra.service_activity.network_access_apps{activity,aggregation_interval_minutes}`, `entra.service_activity.network_access_branches{activity,aggregation_interval_minutes}`, `entra.service_activity.network_access_users{activity,aggregation_interval_minutes}`, `entra.service_activity.signins{activity,aggregation_interval_minutes}` |
| `entra.signin_activity` | Stale service principals / app credentials (no recent sign-in), app sign-in result summary | `graph` | `/reports/servicePrincipalSignInActivities`, `/reports/appCredentialSignInActivities` (beta) | `AuditLog.Read.All`, `Reports.Read.All` | `needs-license/entra_p1`, `beta` | 1h | `entra.app.credential.signin.stale.total{threshold_days}`, `entra.app.signin.summary.total{result}`, `entra.serviceprincipal.signin.stale.total{threshold_days}`, plus a log twin per `entra.app_signin_activity` |
| `entra.syncerrors` | Hybrid directory-sync provisioning errors (onPremisesProvisioningErrors) — UPN/proxy-address conflicts that fail silently while sync freshness stays green — bucketed by object type/category/property, plus a log twin per errored object carrying the conflicting value | `graph` | `/organization` (sync-state probe), `/users` (full page-walk, client-side filtered) | `User.Read.All`, `Organization.Read.All` | runs on every tier (both endpoints are v1.0 stable, not beta); no-ops without paging when the tenant is cloud-only, i.e. onPremisesSyncEnabled is false or null, so only hybrid-synced tenants pay the full /users sweep | 6h | `entra.directory.sync.errors.total{category,object_type,property_causing_error}`, plus a log twin per `entra.directory_sync_error` |
| `entra.tenant_policy` | Tenant policy posture (#245): CIS/benchmark switches as a 0/1 gauge by setting (users can create apps/groups/tenants, read other users; guest-invite restricted; MSOL PowerShell blocked; user consent for risky apps; SSPR; email-verified join; admin-consent workflow; app-management policy; app password-credential restriction), scoped-policy counts by kind, plus one log twin carrying the raw posture (guestUserRoleId, allowInvitesFrom, permission-grant policies) | `graph` | `/policies/authorizationPolicy`, `/policies/adminConsentRequestPolicy`, `/policies/defaultAppManagementPolicy`, `/policies/appManagementPolicies`, `/groupLifecyclePolicies`, `/policies/featureRolloutPolicies` | `Policy.Read.All` | runs on every tier (v1.0, not beta); each singleton fetch is independent and degrades to a non-fatal error; the three scoped-policy collections are empty on m7kni so only their count is emitted (no field mapper written against unseen data) | 6h | `entra.tenant_policy.scoped_policies{kind}`, `entra.tenant_policy.setting{setting}`, plus a log twin per `entra.tenant_policy` |
| `entra.users` | User population by account-enabled/user-type/on-prem-sync (marginal + joint user_type×account_enabled), staleness | `graph` | `/users`, `/users/$count` (`GET /users?…&$count=true` for signInActivity-based slices) | `User.Read.All`, `AuditLog.Read.All` | `partial-license/stale_accounts:entra_p1_or_p2` (staleness slice only, checked inside Collect(): signInActivity needs `entra_p1` or `entra_p2`; the population counts run on every tier) | 15m | `entra.users.population{account_enabled,user_type}`, `entra.users.stale.total{threshold_days}`, `entra.users.total{account_enabled,on_premises_sync_enabled,user_type}` |

## Entra ID — logs (window collectors)

| Collector | Collects | Intrinsic transport | Graph endpoint(s) | Required scope(s) | License / beta / volume | Interval | Lag | Log event |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `entra.agent_risk_detections` | Identity Protection Entra Agent ID (AI-agent) risk detection events — the WHY an agent identity was flagged (admin-confirmed compromise, anomalous agent activity, …), one log per detection. The agent analog of `entra.risk_detections` (users) and `entra.service_principal_risk_detections` (workload identities); log-shaped, the agent-risk STATE gauge ships via `entra.risky_agents`. Beta/Experimental, ungated (200/empty or 403 where the feature is absent) | `graph` | `/beta/identityProtection/agentRiskDetections` | `IdentityRiskyAgent.Read.All` | `beta` | 30m | 15m | `entra.agent_risk_detection` |
| `entra.directory_audits` | Directory audit log events (source: graph\|blob — poll `/auditLogs/directoryAudits`, or consume the `AuditLogs` diagnostic-settings container; exactly one per config). Consent and role-change events additionally carry the changed `modified_property_names`, the assigned `role_name` (from a `Role.DisplayName` change), and the consented `granted_scope` (from an `AppRole.Value` change); property VALUES are never emitted (a `Credential` value can be the credential) | `graph` | `/auditLogs/directoryAudits` | `AuditLog.Read.All` | — | 5m | 15m | `entra.directory_audit` |
| `entra.network_access_traffic` | Global Secure Access per-connection traffic logs (#338) — which device reached which host, under whose identity, allowed or blocked by which rule. The per-connection half of GSA: entra.gsa reports POSTURE (onboarded, which forwarding profiles and filtering policies exist), this reports what actually happened through them, and no other signal in this project names a host a managed device connected to. LOG-ONLY BY DESIGN and emits no metrics at all: every field is per-connection (destination FQDN, user, device, policy rule) and a series keyed by any of them grows with traffic (#112). #239's proposed bounded counter entra.network_access.transactions{traffic_type, action} is deliberately NOT built — a LogQL `count by (traffic_type, action)` over these records answers the identical question at no series cost, and adding a second emit path to the shared logpipeline engine for two series is the wrong trade. One entra.network_access_traffic record per connection, dedupe-keyed on transactionId (NOT connectionId, which carries a trailing ordinal and repeats across a client's connections). Blocked connections are Warn, everything else Info — a block is the security-relevant event, and it is the MAJORITY action on the live tenant (2,941 of 3,000 rows), which is why the volume gate exists rather than why the severity should be lowered. Live-measured 2026-07-29: the record carries two different encodings of absence — ~20 fields are structurally present and set to the EMPTY STRING (sessionId, destinationIp, destinationUrl, threatType, userId/userPrincipalName on service traffic) while httpMethod, responseCode, operationStatus, policyType, isAgentic and every nested detail object use JSON null. Every string goes through SetStr and every nullable scalar is pointer-modeled: a bare int on responseCode would publish a fabricated HTTP 0, and a false isAgentic would claim GSA evaluated a connection it never looked at. The two byte counters are the deliberate exception and are emitted unconditionally INCLUDING zero, because a blocked connection transferred nothing and that measured zero is the evidence the block took effect. destinationWebCategory.name arrives COMMA-JOINED ('ComputersAndTechnology,Business') and is emitted verbatim, never split, for the same reason entra.auth_strength keeps its combinations intact (#322). NOT emitted: processArgs/processTree (a command line can carry a credential in an argument per #190, and both are empty on all 3,000 observed rows so there is no sample to review), the agentic-AI block beyond isAgentic (agenticSessionId, agentVirtualId, agentTypeId/Name, mcpPrimitiveName, mcpProtocolVersion, aiAgentDetails, aiAgentDetectionDetails — null or empty on every observed row, so no mapped shape exists), and any Generative AI prompt/content data, which is #338's own non-goal. A sentinel test proves those values reach no attribute and no body by any path, rather than merely checking the mapper does not use their names | `graph` | `/networkAccess/logs/traffic` (beta; there is no v1.0 form). Query surface all live-measured 2026-07-29: `$top` ceiling is exactly 1000 (1001 answers 400 naming the limit), `$filter` on createdDateTime works including a two-sided window, `$count=true` answers 400 so there is no cheap way to size a window before draining it, and the endpoint's DEFAULT order is createdDateTime DESCENDING — newest first, the opposite of what a watermark poller wants. `$orderby=createdDateTime asc` is accepted but OrderByReliable is deliberately NOT set: accepting an $orderby is not evidence that page order is stable across a paged, throttled, multi-region firehose, so the window is drained and sorted client-side with strict gt/lt bounds (#142/#165). At the measured rate a 5-minute window is ~300 records, inside one page | `NetworkAccess.Read.All` | `beta`, `high-volume opt-in` (OFF by default behind BOTH gates, and both are load-bearing rather than belt-and-braces. HighVolume (#254): **3,695 records in one clock hour**, live-measured 2026-07-29 on a server-filtered 18:00-19:00Z window (4 pages, not truncated) — ~88,700/day from essentially ONE Windows 11 client, so the rate scales with TRAFFIC rather than tenant size. On a real estate this is the largest single stream this project ships. Experimental (#183): the endpoint exists only on Graph beta. An operator needs those two facts independently, which is why neither gate substitutes for the other. Read-only `NetworkAccess.Read.All`, already held by graph2otel-poller since 2026-07-23 — no new grant was needed; do NOT widen to the NetworkAccess.ReadWrite.All or Directory.ReadWrite.All variants the endpoint's own 403 message also lists. initialLookback is one hour and maxWindow six, deliberately short: a day-scale cold start on a busy tenant would drain tens of thousands of records before the first watermark is written. Nothing is watched by wirecheck, recorded as a decision: wirecheck guards values that key a METRIC LABEL, this collector emits no metrics, and a watchdog built on the two observed trafficType/action values would fire on the first legitimate `private` row from Private Access — a correct row, which is the failure this project refuses to ship) | 5m | 15m | `entra.network_access_traffic` |
| `entra.provisioning` | Provisioning (sync) events (source: graph\|blob — poll `/auditLogs/provisioning`, or consume the `ProvisioningLogs` diagnostic-settings container; exactly one per config) | `graph` | `/auditLogs/provisioning` | `AuditLog.Read.All` | — | 15m | 15m | `entra.provisioning` |
| `entra.risk_detections` | Identity Protection risk detection events (source: graph\|blob — poll `/identityProtection/riskDetections`, `$top` capped at 500; or consume the `UserRiskEvents` diagnostic-settings container, which dodges the 1 req/s IPC ceiling — the blob `properties` IS the riskDetection resource, reusing mapRiskDetection; exactly one per config) | `graph` | `/identityProtection/riskDetections` | `IdentityRiskEvent.Read.All` | `needs-license/entra_p2` | 30m | 15m | `entra.risk_detection` |
| `entra.security_alerts` | Security alerts (`alerts_v2`) | `graph` | `/security/alerts_v2` | `SecurityAlert.Read.All` | — | 10m | 15m | `entra.security_alert` |
| `entra.security_incidents` | Security incidents — the correlation layer above `alerts_v2`, grouping related alerts into one investigation (`$top` capped at 50, not 1000) | `graph` | `/security/incidents` | `SecurityIncident.Read.All` | — | 12m | 15m | `entra.security_incident` |
| `entra.signins.interactive` | Interactive sign-in events — the v1.0 default slice, the only sign-in stream that needs no filter and so the only one that is not beta | `graph` | `/auditLogs/signIns` (v1.0, unfiltered) | `AuditLog.Read.All` | `needs-license/entra_p1` | 5m | 15m | `entra.signin` |
| `entra.signins.non_interactive` | Non-interactive sign-in events | `graph` | `/auditLogs/signIns` (beta, `signInEventTypes` filter) | `AuditLog.Read.All` | `needs-license/entra_p1`, `beta` | 5m | 15m | `entra.signin` |
| `entra.signins.service_principal` | Service principal sign-in events | `graph` | `/auditLogs/signIns` (beta, `signInEventTypes` filter) | `AuditLog.Read.All` | `needs-license/entra_p1`, `beta` | 5m | 15m | `entra.signin` |
| `entra.signins.managed_identity` | Managed identity sign-in events | `graph` | `/auditLogs/signIns` (beta, `signInEventTypes` filter) | `AuditLog.Read.All` | `needs-license/entra_p1`, `beta` | 5m | 15m | `entra.signin` |
| `entra.service_principal_risk_detections` | Identity Protection SERVICE-PRINCIPAL (workload-identity) risk detection events — the WHY behind entra.risk's risky-SP gauge (leaked credentials, anomalous SP activity, admin-confirmed compromise, …). One log per detection; log-shaped like `entra.risk_detections`, the SP-risk STATE gauge already ships via `entra.risk`. Ungated (polls unconditionally; returns 200/empty or 403→WARN where the feature is absent — a license gate would hide it on tenants where the endpoint works) | `graph` | `/identityProtection/servicePrincipalRiskDetections` | `IdentityRiskyServicePrincipal.Read.All` | — | 30m | 15m | `entra.service_principal_risk_detection` |

## Entra ID — logs (blob collectors)

| Collector | Collects | Intrinsic transport | Container (diagnostic category) | Cursor key | Required role | License / beta / volume | Interval | Log event |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `entra.directory_audits` | Directory audit log events (source: graph\|blob — poll `/auditLogs/directoryAudits`, or consume the `AuditLogs` diagnostic-settings container; exactly one per config). Consent and role-change events additionally carry the changed `modified_property_names`, the assigned `role_name` (from a `Role.DisplayName` change), and the consented `granted_scope` (from an `AppRole.Value` change); property VALUES are never emitted (a `Credential` value can be the credential) | `blob` | `insights-logs-auditlogs` (AuditLogs) | `insights-logs-auditlogs` | `Storage Blob Data Reader` | — | 15m | `entra.directory_audit` |
| `entra.graph_activity` | One record per Graph API call made against the tenant: which app or user called which endpoint, with which permissions, from where, and what came back. Graph has no endpoint for its own API-call telemetry — none, permanently — so this signal exists only as diagnostic-settings output, and it is what justifies the whole blob path | `blob` | `insights-logs-microsoftgraphactivitylogs` (MicrosoftGraphActivityLogs) | `insights-logs-microsoftgraphactivitylogs` | `Storage Blob Data Reader` | — | 5m | `entra.graph_activity` |
| `entra.graph_notifications` | One record per Graph change-notification publish event: which app owns the subscription, which workload it targets, where it published, and whether it succeeded (`result_status_code`). A change-notification subscription is a persistence/supply-chain foothold — a durable low-noise feed of tenant changes — so `application_id` (the subscription owner) is the load-bearing attribute. Exists only as diagnostic-settings output | `blob` | `insights-logs-graphnotificationsactivitylogs` (GraphNotificationsActivityLogs) | `insights-logs-graphnotificationsactivitylogs` | `Storage Blob Data Reader` | — | 15m | `entra.graph_notifications` |
| `entra.provisioning` | Provisioning (sync) events (source: graph\|blob — poll `/auditLogs/provisioning`, or consume the `ProvisioningLogs` diagnostic-settings container; exactly one per config) | `blob` | `insights-logs-provisioninglogs` (ProvisioningLogs) | `insights-logs-provisioninglogs` | `Storage Blob Data Reader` | — | 15m | `entra.provisioning` |
| `entra.risky_users` | Blob transport for the risky-USER twin (#135-C): the `RiskyUsers` diagnostic-settings category, emitting the same `entra.risky_user` records the polled `entra.risk` twin would (reuses `logTwin`), bound to `riskLastUpdatedDateTime`. Log-only — a separate collector, NOT a source swap: `entra.risk` keeps polling for its bounded (riskLevel, riskState) gauge, and the composition root suppresses only its per-entity twin while this runs (keep-gauges/suppress-twin, blob twin XOR polled twin). Dodges the Identity Protection 1 req/s per-tenant ceiling for the per-entity stream | `blob` | `insights-logs-riskyusers` (RiskyUsers) | `insights-logs-riskyusers` | `Storage Blob Data Reader` | — | 15m | `entra.risky_service_principal`, `entra.risky_user` |
| `entra.risk_detections` | Identity Protection risk detection events (source: graph\|blob — poll `/identityProtection/riskDetections`, `$top` capped at 500; or consume the `UserRiskEvents` diagnostic-settings container, which dodges the 1 req/s IPC ceiling — the blob `properties` IS the riskDetection resource, reusing mapRiskDetection; exactly one per config) | `blob` | `insights-logs-userriskevents` (UserRiskEvents) | `insights-logs-userriskevents` | `Storage Blob Data Reader` | — | 15m | `entra.risk_detection` |
| `entra.signins.microsoft_service_principal` | Sign-ins by Microsoft's own first-party service principals. No `.blob` suffix because this category has no Graph route and so no polled twin to disambiguate from | `blob` | `insights-logs-microsoftserviceprincipalsigninlogs` (MicrosoftServicePrincipalSignInLogs) | `insights-logs-microsoftserviceprincipalsigninlogs` | `Storage Blob Data Reader` | `needs-license/entra_p1` | 5m | `entra.signin` |
| `entra.signins.service_principal.blob` | Service principal sign-in events via storage rather than the beta `signInEventTypes` filter. A drop-in equivalent of the polled twin — same event name, same attributes, same `id`. Measured live at TOTAL id overlap with `entra.signins.service_principal` (1375/1375), so exactly one of the pair may be enabled; registering both is refused at startup | `blob` | `insights-logs-serviceprincipalsigninlogs` (ServicePrincipalSignInLogs) | `insights-logs-serviceprincipalsigninlogs` | `Storage Blob Data Reader` | `needs-license/entra_p1` | 5m | `entra.signin` |
| `entra.signins.non_interactive.blob` | Non-interactive sign-in events via storage rather than the beta `signInEventTypes` filter. A drop-in equivalent of the polled twin — same event name, same attributes, same `id`. Measured live at TOTAL id overlap with `entra.signins.non_interactive` (18/18), so exactly one of the pair may be enabled; registering both is refused at startup | `blob` | `insights-logs-noninteractiveusersigninlogs` (NonInteractiveUserSignInLogs) | `insights-logs-noninteractiveusersigninlogs` | `Storage Blob Data Reader` | `needs-license/entra_p1` | 5m | `entra.signin` |
| `entra.signins.managed_identity.blob` | Managed-identity sign-in events via storage rather than the beta `signInEventTypes` filter. A drop-in equivalent of the polled twin — same event name, same attributes, same `id`. Measured live at TOTAL id overlap with `entra.signins.managed_identity` (1/1, the tenant's only such sign-in), so exactly one of the pair may be enabled; registering both is refused at startup. On when blob ingest is configured (the polled twin is beta/opt-in) | `blob` | `insights-logs-managedidentitysigninlogs` (ManagedIdentitySignInLogs) | `insights-logs-managedidentitysigninlogs` | `Storage Blob Data Reader` | `needs-license/entra_p1` | 5m | `entra.signin` |

## Intune — metrics (snapshot collectors)

| Collector | Collects | Intrinsic transport | Graph endpoint(s) | Required scope(s) | License / beta / volume | Interval | Metric namespace |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `intune.app_install_status` | Per-device app install status, via the Reports Export API: POST a job, poll it, download and parse the CSV. Uses the `AppInstallStatusAggregate` report — the per-app variant has no fleet-wide form | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `graph2otel.api.unexpected{collector,field,kind}`, `intune.app_install_status.installations{install_state,platform}`, plus a log twin per `intune.app_install_status` |
| `intune.apple_tokens` | APNS/VPP token expiry + synced device counts; DEP onboarding settings polled best-effort | `graph` | `/deviceManagement/applePushNotificationCertificate`, `/deviceAppManagement/vppTokens`, `/deviceManagement/depOnboardingSettings` (beta, isolated) | `DeviceManagementServiceConfig.Read.All`, `DeviceManagementApps.Read.All` | APNS/VPP are v1.0 and default-on; the DEP sub-fetch is beta but isolated, so it does not gate the collector | 6h | `intune.apple_token.days_until_expiry{state,token_name,type}`, `intune.apple_token.synced_device_count{token_name}` |
| `intune.app_protection` | App protection (MAM) policy inventory + assignment state; flagged registrations; WIP policy count | `graph` | `/deviceAppManagement/iosManagedAppProtections`, `androidManagedAppProtections`, `targetedManagedAppConfigurations`, `windowsInformationProtectionPolicies`, `mdmWindowsInformationProtectionPolicies` | `DeviceManagementApps.Read.All` | — | 30m | `intune.app_protection.flagged_registrations{flagged_reason,platform}`, `intune.app_protection.policy.count{assigned,platform}`, `intune.wip.policy.count{assigned}`, plus a log twin per `intune.app_registration` |
| `intune.autopilot` | Autopilot device registration + deployment profile state, plus device-registration sync staleness (#248): sync_age_seconds (since the last OEM/partner sync) + sync_status, with a Warn twin when the sync is not healthy — so "registrations stopped arriving" is detectable | `graph` | `/deviceManagement/windowsAutopilotDeviceIdentities`, deployment profiles, `/beta/deviceManagement/windowsAutopilotSettings` (sync singleton) | `DeviceManagementServiceConfig.Read.All` | `beta` | 30m | `graph2otel.api.unexpected{collector,field,kind}`, `intune.autopilot.devices{enrollment_state,group_tag}`, `intune.autopilot.profile.assignments{profile_name}`, `intune.autopilot.profile.count{device_type,preprovisioning_allowed}`, `intune.autopilot.profile.esp_timeout_minutes{profile_name}`, `intune.autopilot.profile.setting{profile_name,setting}`, `intune.autopilot.stale_contact.count{group_tag}`, `intune.autopilot.sync_age_seconds`, `intune.autopilot.sync_status{sync_status}`, plus a log twin per `intune.autopilot.sync` |
| `intune.autopilot_deployment` | Per-device Windows Autopilot device-preparation (V2) deployment outcome (provisioning phase, deployment status, duration, result code), via the Reports Export API. Uses the `AutopilotV2DeploymentStatus` report — V1 returns zero rows on a device-prep tenant. Raw Microsoft status/result codes are emitted verbatim, not decoded | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.autopilot_deployment.deployments{deployment_status}`, plus a log twin per `intune.autopilot_deployment` |
| `intune.autopilot_deployment_apps` | Per-application install status during a Windows Autopilot device-preparation (V2) deployment — the "Apps" tab of the device-deployment-details pane — bucketed by raw `PolicyInstallStatus` code with per-(device, app) detail on the log twin. Uses the `AutopilotV2DeploymentStatusDetailedAppInfo` report. Status codes are emitted verbatim, not decoded; app status is independent of the device deployment outcome | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.autopilot_deployment_apps.installs{policy_install_status}`, plus a log twin per `intune.autopilot_deployment_apps` |
| `intune.autopilot_deployment_scripts` | Per-script execution status during a Windows Autopilot device-preparation (V2) deployment — the "Scripts" tab of the device-deployment-details pane — bucketed by raw `PolicyInstallStatus` code with per-(device, script) detail on the log twin. Uses the `AutopilotV2DeploymentStatusDetailedScriptInfo` report. Empty is a valid steady state on a tenant with no device-prep scripts; status codes are emitted verbatim, not decoded | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.autopilot_deployment_scripts.executions{policy_install_status}`, plus a log twin per `intune.autopilot_deployment_scripts` |
| `intune.device_boot_security` | Per-device Windows boot-security posture (BitLocker, Secure Boot, Code Integrity, VBS, firmware protection, memory integrity, Secured-Core, System Management Mode, TPM), via the Reports Export API. Uses the `WindowsDeviceHealthAttestationReport` report — the deeper posture behind `intune.device_attestation`'s summary; the `deviceHealthAttestationState` managedDevice property is null tenant-wide, so the export is the working path | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.device_boot_security.devices{os,posture,status}`, plus a log twin per `intune.device_boot_security` |
| `intune.certificates` | Certificate state + days-until-expiry | `graph` | `/deviceManagement/deviceConfigurations` (per-profile `managedDeviceCertificateStates`), `/deviceManagement/userPfxCertificates` | `DeviceManagementConfiguration.Read.All` | `beta` | 30m | `graph2otel.api.unexpected{collector,field,kind}`, `intune.certificate.days_until_expiry{cert_profile_name,expiry_bucket,state}`, `intune.certificate.state.count{state}`, plus a log twin per `intune.device_certificate` |
| `intune.cert_inventory` | Device certificate inventory (thumbprints, serials, subject/issuer), via the Reports Export API | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `graph2otel.api.unexpected{collector,field,kind}`, `intune.cert_inventory.days_until_expiry{bucket,issuer}`, `intune.cert_inventory.state{state}`, plus a log twin per `intune.device_certificate` |
| `intune.cloud_pki` | Intune Cloud PKI: the tenant's private certification authorities, when they expire, and every leaf certificate they have issued to a device. When an issuing CA expires, every device authenticating with its certificates loses Wi-Fi/VPN/802.1X AT ONCE, with no gradual degradation first — and `entra.credential_expiry` cannot see it, being a different PKI entirely. Expiry is PARSED OUT OF THE CERTIFICATE, which each CA carries inline as a `data:application/x-x509-ca-cert;base64,…` URI, rather than read from a Graph field: the wire's `validityEndDateTime` agrees on every live row but is used only as a fallback, and a disagreement is emitted as `declared_valid_to` and escalated. `expiry_source` labels a degraded (fallback) reading so it can never be mistaken for a measured one, and a CA whose expiry cannot be established emits NO gauge point rather than a fabricated zero. Three bounded gauges (CAs by type × status, days-until-expiry per CA, leaf certificates by CA × status) plus a twin per CA (decoded subject and issuer, key platform, CRL/SCEP endpoints) and a twin per LEAF certificate naming the device and user it belongs to. The certificate blob is never emitted. Volume: one leaf twin per issued certificate INCLUDING retained expired and revoked ones — 69 records for a five-device tenant (live-measured 2026-07-24), which is why the interval is 12h. Severity thresholds match `entra.credential_expiry`'s buckets exactly so the two agree on what "expiring soon" means | `graph` | `GET /beta/deviceManagement/cloudCertificationAuthority`, then `GET /beta/deviceManagement/cloudCertificationAuthority/{id}/cloudCertificationAuthorityLeafCertificate` once per CA. The top-level `/beta/deviceManagement/cloudCertificationAuthorityLeafCertificate` list is declared in the beta EDM but 404s; the per-CA navigation is the working route (live-measured 2026-07-24) | `DeviceManagementCloudCA.Read.All` | `beta` (read-only `DeviceManagementCloudCA.Read.All`; beta-only surface (v1.0 has no such segment), so Experimental — opt-in via explicit enable) | 12h | `intune.cloud_pki.authorities{certification_authority_status,certification_authority_type}`, `intune.cloud_pki.days_until_expiry{certification_authority_type,display_name}`, `intune.cloud_pki.leaf_certificates{certificate_status,display_name}`, plus a log twin per `intune.cloud_pki_authority`, `intune.cloud_pki_leaf_certificate` |
| `intune.compliance` | Tenant-wide + per-policy compliance state rollups | `graph` | `/deviceManagement/deviceCompliancePolicies`, device compliance states | `DeviceManagementConfiguration.Read.All` | — | 15m | `intune.compliance.devices{state}`, `intune.compliance.policy.devices{policy_name,state}`, `intune.compliance.policy.users{policy_name,state}`, `intune.compliance.policy.version{policy_name}`, `intune.compliance.setting.devices{platform,setting_name,state}` |
| `intune.config_assignment_status` | Per-device configuration-policy assignment status/failures (succeeded/pending/error/conflict/noncompliant), via the Reports Export API. Uses the `DeviceAssignmentStatusByConfigurationPolicy` report — one row per device×policy assignment | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.config_assignment_status.assignments{policy_platform,report_status}`, plus a log twin per `intune.config_assignment_status` |
| `intune.config_profile_device_status` | Per-(device, configuration profile) applied status — `ReportStatus` succeeded/error/**conflict**/noncompliant with the raw `PolicyStatus` code — via the Reports Export API. Uses the `DeviceStatusesByConfigurationProfile` report; metric counts by report_status, per-device detail on the log twin (WARN on error/conflict/noncompliant) | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.config_profile_device_status.devices{report_status}`, plus a log twin per `intune.config_profile_device_status` |
| `intune.config_profiles` | Configuration profile status + version, per-setting state | `graph` | `/deviceManagement/deviceConfigurations` (fan-out per profile) | `DeviceManagementConfiguration.Read.All` | — | 30m | `graph2otel.api.unexpected{collector,field,kind}`, `intune.config_profile.count{odata_type}`, `intune.config_profile.status{profile_name,state}`, `intune.config_profile.version{profile_name}` |
| `intune.config_setting_status` | Per-setting configuration-policy device summary — how many assigned devices are compliant, errored, or in **conflict** on each setting — via the Reports Export API. Uses the `PerSettingDeviceSummaryByConfigurationPolicy` report; metric sums compliant/error/conflict device counts (three bounded series), per-(policy, setting) counts on the log twin (WARN on any error or conflict device) | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.config_setting_status.devices{setting_device_status}`, plus a log twin per `intune.config_setting_status` |
| `intune.connectors` | Exchange/MTD/NDES/Managed-Google-Play connector health. Managed Google Play (#248) folds in as a fourth connector_type on the existing state + heartbeat_age_seconds metrics (no new metric names) with a Warn twin — a broken Android Enterprise bind stops all Android app delivery silently | `graph` | `/deviceManagement/exchangeConnectors`, `/deviceManagement/mobileThreatDefenseConnectors`, NDES (beta, isolated), `/beta/deviceManagement/androidManagedStoreAccountEnterpriseSettings` (beta, isolated) | `DeviceManagementServiceConfig.Read.All` | Exchange/MTD are default-on; the NDES and Managed-Google-Play sub-fetches are beta and isolated, so their failure does not gate the collector | 15m | `intune.connector.heartbeat_age_seconds{connector_type}`, `intune.connector.mtd_platform.total{enabled,platform}`, `intune.connector.state{connector_type,state}`, plus a log twin per `intune.connector` |
| `intune.defender_agents` | Defender agent health, via the Reports Export API | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.defender_agents.count{signal}`, `intune.defender_agents.product_status{status}`, plus a log twin per `intune.defender_agent` |
| `intune.detected_apps` | Detected-apps software inventory catalog. Every catalog row becomes a `device_count` series, summed across versions and folded case-insensitively (Intune emits casing variants of one application). The fixed eight-app allow-list this collector used to promote from was retired in #235 — the catalog is unbounded, but it is bounded by the central cardinality limiter now (top N by device count, tail into `app_name="other"`) rather than by a standing guess about which applications matter, which on a real tenant promoted zero series | `graph` | `/deviceManagement/detectedApps` | `DeviceManagementManagedDevices.Read.All` | — | 1h | `intune.detected_apps.catalog_size`, `intune.detected_apps.device_count{app_name,platform}` |
| `intune.device_attestation` | Per-device TPM/health attestation status (attestation state, TPM manufacturer/version, model), via the Reports Export API. Uses the `TpmAttestationStatus` report — the `deviceHealthAttestationState` managedDevice property is null tenant-wide (live-measured), so the export is the working path | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.device_attestation.devices{attestation_status,os}`, plus a log twin per `intune.device_attestation` |
| `intune.device_encryption` | Per-device disk-encryption posture: whether the disk is actually encrypted, whether the device is even READY to be encrypted, whether an encryption policy reached it, and — for Windows — the specific BitLocker blockers (`osVolumeUnprotected`, `tpmNotReady`, `osVolumeEncryptionMethodMismatch`, …). The complementary "why is it not encrypted" detail behind `intune.devices`' coarse `isEncrypted` boolean, which exists on no other surface. Two bounded gauges (encryption state × readiness × device type, and policy setting state) plus a per-device log twin; `deviceType` is emitted as the verbatim Intune wire enum (`windowsRT`, `macMDM`), and the comma-joined `advancedBitLockerStates` flag list rides the twin only — its combinations are unbounded | `graph` | `GET /deviceManagement/managedDeviceEncryptionStates` (beta — v1.0 has no such segment) | `DeviceManagementManagedDevices.Read.All` | `beta` (read-only `DeviceManagementManagedDevices.Read.All`; beta endpoint, so opt-in via explicit enable) | 1h | `graph2otel.api.unexpected{collector,field,kind}`, `intune.device_encryption.devices{device_type,encryption_readiness_state,encryption_state}`, `intune.device_encryption.policy_state{encryption_policy_setting_state}`, plus a log twin per `intune.device_encryption` |
| `intune.devices_without_compliance_policy` | Managed devices with no compliance policy assigned — a posture blind-spot — via the Reports Export API. Uses the `DevicesWithoutCompliancePolicy` report; metric counts by OS, per-device detail on the log twin (always WARN). Empty is the healthy steady state | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.devices_without_compliance_policy.devices{os}`, plus a log twin per `intune.devices_without_compliance_policy` |
| `intune.driver_update_summary` | Windows driver-update policy device counts by deployment state (error/in_progress/success/cancelled), via the Reports Export API. Uses the `DriverUpdatePolicyStatusSummary` report — the driver sibling of the feature/quality update summaries; pre-aggregated gauge, no log twin | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.driver_update_summary.devices{policy_id,policy_name,update_deployment_state}` |
| `intune.endpoint_analytics` | UXA per-device scores, per-MODEL score rollups (`intune.uxa.model_score` / `model_device_count`, bounded by the distinct (model, manufacturer) pairs in the fleet — Microsoft publishes buckets as small as one device, so the count is the real bucket size, not a floor), boot/login time histograms, app crash counts, battery health, resource performance, anomaly-severity counts, per-device Windows 11 upgrade-readiness (the Work-From-Anywhere metricDevices navigation — eligibility, failed hardware checks, cloud posture), per-process startup impact, and per-device app health — the heaviest collector. Every per-entity sub-fetch emits a log twin as of #225, which withdrew the #114 no-twin exception this collector used to carry: the bounded metrics answer "how many devices are in this state", the twins answer "which device, and why" — including the battery age and max-capacity behind a battery score, and the Windows crash-bucket identifiers (`restartStopCode`, `restartFaultBucket`) behind a blue-screen count. The startup twin is stamped with the boot's own `startTime` rather than poll time; startup processes are twin-only, since the (device, process) pair is unbounded. Application-level app health is twinned for EVERY application (`intune.app_health`), and `intune.uxa.app_crash_count` now covers every executable too: its fixed allow-list of ten common binaries was retired in #235, having discarded 100% of the live data on m7kni (the one row the tenant produces is `LogonUI.exe`) and made a line-of-business app crashing thousands of times structurally invisible. The executable set is unbounded, so the metric is bounded by the central cardinality limiter instead — top N by crash count, tail folded into `app_name="other"` — which ranks by the thing a crash metric is for | `graph` | `/deviceManagement/userExperienceAnalytics*` (v1.0 + beta) | `DeviceManagementManagedDevices.Read.All` | `beta` | 1h | `intune.uxa.anomaly_count{anomaly_severity}`, `intune.uxa.app_crash_count{app_name}`, `intune.uxa.app_health.active_device_count{os_version}`, `intune.uxa.app_health.device_count{health_state}`, `intune.uxa.app_health.mean_time_to_failure_minutes{os_version}`, `intune.uxa.app_health.os_version_score{health_state,os_version}`, `intune.uxa.baseline_score{baseline_name,is_built_in}`, `intune.uxa.battery_health.device_count{health_state}`, `intune.uxa.battery_health_score{health_state}`, `intune.uxa.boot_time_ms{restart_category}`, `intune.uxa.device_count{health_state}`, `intune.uxa.device_score{category}`, `intune.uxa.login_time_ms{restart_category}`, `intune.uxa.model_device_count{health_state,manufacturer,model}`, `intune.uxa.model_score{category,health_state,manufacturer,model}`, `intune.uxa.resource_performance.device_count{health_state}`, `intune.uxa.resource_performance_score{health_state}`, `intune.uxa.work_from_anywhere.device_count{health_state,upgrade_eligibility}`, plus a log twin per `intune.app_health`, `intune.device_app_health`, `intune.device_battery_health`, `intune.device_endpoint_analytics`, `intune.device_resource_performance`, `intune.device_startup`, `intune.device_startup_process`, `intune.device_work_from_anywhere` |
| `intune.enrollment` | Enrollment configuration inventory (restrictions, VPP, ESP, etc.) + priority + version | `graph` | `/deviceManagement/deviceEnrollmentConfigurations` | `DeviceManagementServiceConfig.Read.All` | — | 15m | `graph2otel.api.unexpected{collector,field,kind}`, `intune.enrollment_config.count{config_type}`, `intune.enrollment_config.priority{config_name,config_type}`, `intune.enrollment_config.version{config_name}` |
| `intune.epm_denied` | Endpoint Privilege Management denied elevations — which applications were blocked from elevating, for whom, and how often — via the Reports Export API. Uses the `EpmDeniedReport` report; metric counts by elevation_type, per-denial detail (device, user, file, hash) on the log twin (always WARN). Empty is the steady state on a tenant with no denials | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.epm_denied.denials{elevation_type}`, plus a log twin per `intune.epm_denied` |
| `intune.epm_elevation_events` | Per-elevation Endpoint Privilege Management event stream — one log record per privilege elevation on a managed device (which binary ran elevated, by whom, on which device, under what EPM policy, and whether governed), via the Reports Export API. Uses the `EpmElevationReportElevationEvent` report — the per-event detail behind the `intune.epm_elevations` aggregate. Checkpoints a watermark + seen-id set over the export transport so each elevation is emitted exactly once (stamped with its own EventDateTime) rather than re-emitted on every poll; the metric is a bounded counter by elevation type and result, and per-event detail rides the log twin | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.epm_elevation_events.count{elevation_type,result}`, plus a log twin per `intune.epm_elevation_events` |
| `intune.epm_elevations` | Endpoint Privilege Management application elevations — which applications ran elevated, how often, and whether the elevation was policy-governed (unmanaged elevations are a security signal), via the Reports Export API. Uses the `EpmAggregationReportByApplication` report | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.epm_elevations.count{elevation_type}`, plus a log twin per `intune.epm_elevations` |
| `intune.epm_elevations_by_publisher` | Endpoint Privilege Management elevations attributed to the signing PUBLISHER — whose software is being run elevated on managed devices, how often, and whether the elevation was policy-governed — via the Reports Export API. Uses the `EpmAggregationReportByPublisher` report, the publisher cut of the `intune.epm_elevations` application rollup. Metric sums `ElevationCount` by elevation type (wire enum verbatim), per-publisher detail on the log twin (WARN on an unmanaged elevation) | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.epm_elevations_by_publisher.count{elevation_type}`, plus a log twin per `intune.epm_elevations_by_publisher` |
| `intune.epm_elevations_by_user` | Endpoint Privilege Management elevations attributed to the USER — who is elevating, how often, and how much of it is governed by an EPM policy — via the Reports Export API. Uses the `EpmAggregationReportByUser` report, the user cut of the `intune.epm_elevations` application rollup. Metric sums the per-user managed/unmanaged counts into exactly two bounded series (both always emitted, even at zero); the user's UPN and its three counts ride the log twin (WARN when the user has any unmanaged elevation). The `Upn` column is emitted verbatim — it is not always a real UPN (a down-level `AzureAD\user` logon name is live-observed) | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.epm_elevations_by_user.count{elevation_governance}`, plus a log twin per `intune.epm_elevations_by_user` |
| `intune.feature_update_devices` | WHICH device failed a Windows feature update, and why (#351) — the per-device residual #38/#193 named explicitly when they shipped the pre-aggregated summary. `intune.feature_update_summary` answers how many devices are stuck per policy and structurally cannot name one; this fetches the per-device report and emits one `intune.feature_update_device` twin per row carrying device id, device name, UPN, the policy, and the aggregate/current/alert states. Bounded gauges .states{policy_name, aggregate_state}, .update_status{policy_name, device_update_status} and .alerts{policy_name, latest_alert_message} — keyed by policy (bounded by tenant CONFIGURATION, not tenant size, the same bound the summary collector already uses) and by closed status codes. Device id, name and UPN are never metric labels (#112). THE TRAP, live-measured 2026-07-28: every status column ships TWICE, a bare machine value and a `_loc` LOCALIZED display string — `CurrentDeviceUpdateStatus` is the code `8` while `CurrentDeviceUpdateStatus_loc` is the English `Installed`. The bare column keys the gauge and the localized one rides the twin, because a metric label whose value changes with the tenant's display language silently splits one series into two; the twin needs both, since `8` alone is unreadable and `Installed` alone is unstable. The live 9-row export is what makes this concrete rather than theoretical: it carries BOTH states, and `AggregateState` renders `Success`->`Success` (identical) but `InProgress`->`In progress` (different) — so a mapper sampling only the healthy rows would conclude the two columns are interchangeable, key the gauge off the localized one, and stay invisibly wrong until a device happened to be mid-update. Nothing is declared to internal/wirecheck: two observed values across one tenant's nine devices is not a value set (#234) | `report_export` | `POST /deviceManagement/reports/exportJobs` for the `FeatureUpdateDeviceState` report, filtered `(PolicyId eq '…')`, ONE JOB PER FEATURE-UPDATE PROFILE. Fan-out is 1 + N where N is the profile count — bounded by tenant configuration, not tenant size (2 profiles live, 9 device rows, job completed in ~8s). The shared 48/min export-job limiter is what N is spent against, so even a hundred-profile tenant fits inside one cycle at the long default interval; the per-collector enable/interval keys are the cost gate, and no new config surface is added. A failed policy is logged and skipped without discarding the policies that succeeded | `DeviceManagementConfiguration.Read.All`, `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state. Same documented write-scope break as intune.feature_update_summary and the other export-job collectors) | 6h | `intune.feature_update_devices.alerts{latest_alert_message,policy_name}`, `intune.feature_update_devices.states{aggregate_state,policy_name}`, `intune.feature_update_devices.update_status{device_update_status,policy_name}`, plus a log twin per `intune.feature_update_device` |
| `intune.feature_update_summary` | Per-policy Windows feature-update deployment rollup (devices in-progress / errored / succeeded, by policy and target version), via the Reports Export API. Uses the `FeatureUpdatePolicyStatusSummary` report — the "Deployment status per update ring" Monitor report. Pre-aggregated, so it emits a bounded gauge keyed by policy and no per-device twin | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.feature_update_summary.devices{feature_update_version,policy_id,policy_name,update_deployment_state}` |
| `intune.firewall_status` | Per-device endpoint firewall health (raw `FirewallStatus` code; 0 = Enabled), via the Reports Export API. Uses the `FirewallStatus` report; metric counts by firewall status code, per-device detail on the log twin (WARN when not enabled) | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.firewall_status.devices{firewall_status}`, plus a log twin per `intune.firewall_status` |
| `intune.gpo_analytics` | GPO migration readiness/analytics reports | `graph` | `/deviceManagement/groupPolicyMigrationReports`, `/deviceManagement/groupPolicyConfigurations` | `DeviceManagementConfiguration.Read.All` | `beta` | 24h | `graph2otel.api.unexpected{collector,field,kind}`, `intune.gpo.config.count{ingestion_type}`, `intune.gpo.migration_readiness{readiness,report_name}`, `intune.gpo.supported_settings_percent{report_name}` |
| `intune.hardware_inventory` | Per-device HARDWARE inventory — disk capacity and free space, TPM chip identity (specification triple, manufacturer, firmware version), system firmware/BIOS version, Windows Device Guard VBS + Credential Guard state, OS edition/product type/language, device licensing status, battery level, wired IPv4 addresses, and mobile identity (IMEI, eSIM, phone number, carrier). `hardwareInformation` exists only on the BETA managedDevice type and materializes only on a SINGLE-ENTITY GET — the list form returns a near-empty stub and `$expand` is rejected — so the fleet is swept through `POST /$batch` in chunks of 20, costing `1 + ceil(N/20)` requests per cycle (the most expensive fetch shape in graph2otel, which is why the interval is 24h: Intune refreshes hardware inventory on a multi-day cycle). Deliberately does NOT re-emit what `intune.devices` already carries (serial, model, wifi MAC, isEncrypted, UPN) — device id and name ride the twin purely as the join key back to `intune.managed_device`. Wire traps handled explicitly: `batteryHealthPercentage`/`batteryChargeCycles` read 0 on every device including working laptops (0 = "not reported", attribute omitted), `totalStorageSpace`=0 on a running Linux host is likewise excluded rather than summed as zero, `deviceGuard*` reports Windows-only values on macOS/iOS/Linux and is emitted verbatim (never a non-Windows security posture), and `tpmSpecificationVersion` is a comma-joined triple (`2.0, 0, 1.64`), not a version number | `graph` | `GET /deviceManagement/managedDevices` for ids, then `POST /$batch` of `GET /deviceManagement/managedDevices/{id}?$select=hardwareInformation` in chunks of 20 (beta — v1.0 has no `hardwareInformation` property) | `DeviceManagementManagedDevices.Read.All` | `beta` (read-only `DeviceManagementManagedDevices.Read.All`; beta-only property, so opt-in via explicit enable) | 24h | `graph2otel.api.unexpected{collector,field,kind}`, `intune.hardware_inventory.device_guard_devices{credential_guard_state,vbs_state}`, `intune.hardware_inventory.devices{manufacturer,operating_system}`, `intune.hardware_inventory.storage_bytes{operating_system,storage_state}`, `intune.hardware_inventory.tpm_devices{tpm_specification_version}`, plus a log twin per `intune.device_hardware` |
| `intune.malware` | Tenant malware/Defender overview (detected devices, by severity/category), per-device Defender protection/product state | `graph` | `/deviceManagement/windowsMalwareOverview`, `/deviceManagement/managedDevices/{id}/windowsProtectionState` | `DeviceManagementManagedDevices.Read.All` | — | 30m | `graph2otel.api.unexpected{collector,field,kind}`, `intune.defender.product_status{status}`, `intune.defender.protection_state{signal}`, `intune.malware.overview.by_category{category}`, `intune.malware.overview.by_severity{severity}`, `intune.malware.overview.detected_devices`, `intune.malware.overview.total`, plus a log twin per `intune.device_malware_state` |
| `intune.devices` | Managed-device inventory, encryption, sync recency, enrolled/MDM/dual-enrolled overview, plus a log twin per device. The full-fleet page-walk is irreducible by design: the per-device twins ARE the deliverable, so the bounded `managedDeviceOverview` cross-check cannot replace it | `graph` | `/deviceManagement/managedDevices`, `managedDeviceOverview` | `DeviceManagementManagedDevices.Read.All` | — | 1h | `graph2otel.api.unexpected{collector,field,kind}`, `intune.devices.count{compliance_state,operating_system}`, `intune.devices.encrypted.count{operating_system}`, `intune.devices.os_version.count{operating_system,os_version}`, `intune.devices.overview.dual_enrolled_device_count`, `intune.devices.overview.enrolled_device_count`, `intune.devices.overview.mdm_enrolled_device_count`, `intune.devices.overview.total{os}`, `intune.devices.sync_staleness_seconds{staleness_bucket}`, plus a log twin per `intune.managed_device` |
| `intune.mobile_apps` | Mobile app catalog (type, publishing state); mobile app config policy status | `graph` | `/deviceAppManagement/mobileApps`, app configs | `DeviceManagementApps.Read.All` | — | 30m | `graph2otel.api.unexpected{collector,field,kind}`, `intune.mobile_app_config.status{policy_name,status}`, `intune.mobile_apps.count{app_type,publishing_state}` |
| `intune.noncompliant_settings` | Per-device, per-setting compliance failures — which specific setting is noncompliant on which device — via the Reports Export API. Uses the `NoncompliantDevicesAndSettings` report, the detail the summary-only `intune.compliance` gauges cannot answer | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `graph2otel.api.unexpected{collector,field,kind}`, `intune.noncompliant_settings.count{os,setting_status}`, plus a log twin per `intune.noncompliant_setting` |
| `intune.quality_update_summary` | Per-policy Windows quality/expedite-update deployment rollup (devices in-progress / errored / succeeded, by policy and expedite release date), via the Reports Export API. Uses the `QualityUpdatePolicyStatusSummary` report — the "Security update status" Monitor report. Pre-aggregated, so it emits a bounded gauge keyed by policy and no per-device twin | `report_export` | `POST /deviceManagement/reports/exportJobs` | `DeviceManagementManagedDevices.ReadWrite.All` | `beta` (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state) | 6h | `intune.quality_update_summary.devices{expedite_release_date,policy_id,policy_name,update_deployment_state}` |
| `intune.rbac` | Intune's OWN role store — role definitions, the assignments binding them to principals, and the link between the two. Entirely separate from Entra directory roles, so `entra.roles` sees none of it and a principal holding sweeping device rights through an Intune CUSTOM role (wipe, retire, app assignment, script execution) was previously invisible while a Global Reader showed up immediately. Two bounded gauges — definitions by both built-in flags × `@odata.type` subtype, assignments by scope type × whether the granted role is built-in — plus a twin per definition (the full unbounded `Microsoft.Intune_*` action list, the explicit deny list, scope tags, assignment count) and a twin per assignment, which Warns when a NON-built-in (or unresolvable) role is assigned at `allDevicesAndLicensedUsers`, the tenant's widest scope. `isBuiltIn` and `isBuiltInRoleDefinition` are BOTH emitted as separate gauge dimensions rather than assumed to be one field renamed, and the emitted action list is the union of `permissions` and `rolePermissions` (byte-identical on every live row) so a divergence loses nothing. Assignment `members` are emitted as BARE PRINCIPAL GUIDS, deliberately unresolved: Graph returns no names, and an unresolved guid is honest where a name resolved out of a different store can be wrong — the guid joins to `entra.groups`/`entra.users` in the backend | `graph` | `GET /deviceManagement/roleDefinitions`, `GET /deviceManagement/roleAssignments`, then `GET /deviceManagement/roleDefinitions/{id}/roleAssignments` once per definition — the ONLY working assignment→role link (the assignment row carries no roleDefinitionId, and `$expand=roleDefinition` answers 200 with `null` while silently dropping `scopeType`, `scopeMembers` and `roleScopeTagIds`). The fan-out is bounded by the role CATALOG, never by tenant size | `DeviceManagementRBAC.Read.All` | read-only `DeviceManagementRBAC.Read.All`; v1.0 (and beta) both serve these collections, so default-on and NOT Experimental | 6h | `intune.rbac.role_assignments{is_built_in,scope_type}`, `intune.rbac.role_definitions{is_built_in,is_built_in_role_definition,role_definition_type}`, plus a log twin per `intune.role_assignment`, `intune.role_definition` |
| `intune.remediation_run_states` | Per-device proactive-remediation health — for each remediation (deviceHealthScript), which devices its detection script passed or FAILED, the detection script's own output message, and whether a remediation ran. Emits a bounded gauge counted by remediation, detection state and remediation state, plus a per-(remediation, device) log twin carrying the device, OS and detection message. Chosen read-only over the `DeviceRunStatesByProactiveRemediation` export report — same data, no write scope and no per-policy export fan-out | `graph` | `GET /deviceManagement/deviceHealthScripts/{id}/deviceRunStates` (beta) | `DeviceManagementConfiguration.Read.All`, `DeviceManagementManagedDevices.Read.All` | `beta` (read-only `DeviceManagementConfiguration.Read.All` + `DeviceManagementManagedDevices.Read.All`; beta endpoint, so opt-in via explicit enable) | 1h | `graph2otel.api.unexpected{collector,field,kind}`, `intune.remediation_run_states.devices{detection_state,remediation_name,remediation_state}`, plus a log twin per `intune.remediation_run_states` |
| `intune.scripts` | Script/remediation inventory, run summaries, and remediation overview | `graph` | `/deviceManagement/deviceManagementScripts` (Windows), `deviceShellScripts` (macOS), `deviceHealthScripts` (+ `getRemediationSummary`) | `DeviceManagementScripts.Read.All` | `beta` | 30m | `intune.remediation.overview.remediated_device_count`, `intune.remediation.overview.script_count`, `intune.remediation.remediated_cumulative_devices{script_name}`, `intune.remediation.summary{phase,script_name,state}`, `intune.script.run_summary{os,run_state,script_name,target}` |
| `intune.settings_catalog` | Settings Catalog policy inventory, template-based intents + per-intent device state, security baseline device state | `graph` | `/deviceManagement/configurationPolicies` (beta), `/deviceManagement/intents` (+ `deviceStateSummary`), `/deviceManagement/templates/{id}/deviceStateSummary` | `DeviceManagementConfiguration.Read.All` | `beta` | 30m | `intune.intent.count{migrating}`, `intune.intent.devices{compliance_status,intent_name}`, `intune.security_baseline.devices{baseline_name,state}`, `intune.settings_catalog.policy.count{platform,technology,template_family}` |
| `intune.updates` | Windows Update rings + feature/quality/driver update profile state, pause/rollback | `graph` | `/deviceManagement/deviceConfigurations` (ring subtype only, v1.0), `/deviceManagement/windowsFeatureUpdateProfiles`, `windowsQualityUpdateProfiles`/`Policies`, `windowsDriverUpdateProfiles` (beta) | `DeviceManagementConfiguration.Read.All` | `beta` (the whole collector is gated as one unit: its most-valuable signal — the feature/quality/driver profile families — is beta-only, and the ring metrics, though v1.0-sourced, ship inside the same opt-in rather than splitting into a separate v1.0-default collector) | 30m | `intune.driver_update.pending_approval{profile_name}`, `intune.driver_update.sync_staleness_seconds{profile_name}`, `intune.feature_update_profile.eol_target{feature_update_version,profile_name}`, `intune.quality_update_config.count{resource_type}`, `intune.update_ring.pause_expiry_seconds{ring_name,update_type}`, `intune.update_ring.pause_state{ring_name,update_type}`, `intune.update_ring.rollback_active{ring_name,update_type}`, `intune.update_ring.status{ring_name,state}` |
| `intune.windows_updates` | The Windows Update for Business DEPLOYMENT SERVICE — update policies (what is auto-approved, under which content filter, with what user experience) and deployments (what is actually being offered to devices right now). A different API from `intune.updates`, which owns the classic update rings and `windows*UpdateProfiles`; these are peers, not duplicates. The signal it exists for is the THREE-part `state` object: `effectiveValue`, `requestedValue` and `reasons[]`, which DISAGREE on the live wire (`offering` under a requested `none`) — a deployment not doing what was asked is the one thing this surface can say that nothing else can, so the two states are separate gauge dimensions and a mismatch drives a WARN twin. Every `@odata.type` discriminator is read rather than inferred (`contentApprovalRule`, `qualityUpdateFilter` vs `driverUpdateFilter`, `catalogContent`, `qualityUpdateCatalogEntry`): the quality-only `classification`/`cadence` fields are mapped only when the discriminator names the quality variant, because a driver filter carries neither and both live policies prove it. Wire traps handled explicitly: the nested `catalogEntry` sends `id: ""` and `displayName: null`, and an empty id is omitted rather than emitted as an identifier; `lastEvaluatedDateTime` is the .NET zero date `0001-01-01T00:00:00Z` on every rule ("never evaluated"), never emitted as a timestamp but counted as `rules_never_evaluated`; `durationBeforeDeploymentStart` is an ISO-8601 duration STRING carried verbatim. The `products` catalog is deliberately NOT collected — 17 rows of Microsoft's global Windows product list, identical on every tenant, which would be a constant as a metric and re-emitted noise as logs, and which no record graph2otel maps carries an id into | `graph` | `GET /admin/windows/updates/updatePolicies`, `GET /admin/windows/updates/deployments` (beta — v1.0 rejects the segment: 400 "Resource not found for the segment 'windows'") | `WindowsUpdates.Read.All` | `beta` (read-only `WindowsUpdates.Read.All`; beta-only surface, so opt-in via explicit enable) | 1h | `intune.windows_updates.deployments{catalog_entry_type,deployment_effective_state,deployment_requested_state}`, `intune.windows_updates.update_policies{update_category}`, plus a log twin per `intune.windows_update_deployment`, `intune.windows_update_policy` |

## Intune — logs (window collectors)

| Collector | Collects | Intrinsic transport | Graph endpoint(s) | Required scope(s) | License / beta / volume | Interval | Lag | Log event |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `intune.audit_events` | Intune audit events. Emits the NAMES of changed `modifiedProperties` but never their old/new values, which can carry credentials and certificates — the one genuine content exclusion in graph2otel | `graph` | `/deviceManagement/auditEvents` | `DeviceManagementApps.Read.All` | — | 15m | 15m | `intune.audit_event` |
| `intune.autopilot_events` | Autopilot deployment/enrollment events. Also rejects a server-side time `$filter`, so the window is bounded client-side | `graph` | `/deviceManagement/autopilotEvents` (beta, no v1.0 equivalent) | `DeviceManagementManagedDevices.Read.All` | `beta` | 20m | — | `intune.autopilot_event` |
| `intune.enrollment_events` | Enrollment troubleshooting events. The endpoint rejects a server-side `$filter` on its time field, so the window is bounded client-side instead | `graph` | `/deviceManagement/troubleshootingEvents` | `DeviceManagementManagedDevices.Read.All` | `needs-license/intune` | 20m | 15m | `intune.enrollment_event` |

## Intune — logs (blob collectors)

| Collector | Collects | Intrinsic transport | Container (diagnostic category) | Cursor key | Required role | License / beta / volume | Interval | Log event |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `intune.cloud_pc_audit` | One log per Windows 365 / Cloud PC admin operation (provisioning-policy create/patch/delete, user-setting change, group assignment, reprovision, grace-period end) — the CloudPC control-plane audit trail, which has no Graph endpoint and exists only as a diagnostic-settings category. The intune.audit_events peer for Cloud PC: actor, target resource(s), and the NAMES of changed properties (never their values, #112). On when blob ingest is configured | `blob` | `insights-logs-windows365auditlogs` (Windows365AuditLogs) | `insights-logs-windows365auditlogs` | `Storage Blob Data Reader` | — | 15m | `intune.cloud_pc_audit` |
| `intune.compliance_alerts` | One record per Intune compliance fired-event — the "managed device X is not compliant" alerts an operator acts on, naming the device (host/NetBIOS/DNS), its owner (`user_name`/`upn_suffix`), and which compliance rule failed (the setting path in `description`). Graph exposes only the notification templates, not the fired events (#94). Emitted Warn: a device fell out of compliance | `blob` | `insights-logs-operationallogs` (OperationalLogs) | `insights-logs-operationallogs` | `Storage Blob Data Reader` | — | 15m | `intune.compliance_alert` |
| `intune.devices_blob` | Blob transport for the per-device twin (#135-F): the `Devices` diagnostic-settings category, emitting the same `intune.managed_device` records the polled `intune.devices` twin would (reuses `deviceLogTwin`) — but the blob report uses PascalCase field names AND different enum VALUES, so each field is normalized onto the Graph shape first (`CompliantState "Compliant"`→`compliant`, `OS "MacOS"`→`macOS`, `EncryptionStatusString "True"`→bool), verified against both live shapes. A separate log-only collector, NOT a source swap: `intune.devices` keeps polling for its bounded fleet gauges (the blob inventory dump can't produce counts), and the composition root suppresses only its per-device twin while this runs (keep-gauges/suppress-twin). Staleness is computed against the snapshot's envelope time; the per-batch Stats summary record is skipped | `blob` | `insights-logs-devices` (Devices) | `insights-logs-devices` | `Storage Blob Data Reader` | — | 15m | `intune.managed_device` |

## M365 — logs (window collectors)

| Collector | Collects | Intrinsic transport | Graph endpoint(s) | Required scope(s) | License / beta / volume | Interval | Lag | Log event |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `m365.message_trace` | Per-message mail flow from Exchange Online's message trace (#254) — the only per-message record that does not depend on Defender for Office 365 streaming export being enabled and billed. On a tenant without that export it is the sole answer to "did this message arrive, and what happened to it", the most common mail question an operator is ever asked. It joins to `defender.email*` and `defender.quarantine` on `internet_message_id`, which is the same attribute key those collectors already emit — no translation table. SHIPS OFF BY DEFAULT: it declares HighVolume (one record per message PER RECIPIENT, so volume scales with mail TRAFFIC, not tenant size) and registers only when config explicitly enables it. MEASURED VOLUME: 28 records / 24h on m7kni across 3 mailboxes — about 9 per mailbox per day; m7kni is a tiny tenant, so extrapolate per mailbox, never per tenant. Counters m365.message_trace.messages{status} and .bytes{status}, labeled by delivery status and nothing else — counters rather than a window gauge because the poll window varies (a catch-up tick runs up to MaxWindow), which would make a window count spike by the window ratio and look identical to a real mail surge. All per-message detail — message ids, sender, recipient, subject, both IPs, size — is on the log twin only (#112); a metric keyed by recipient is one series per mailbox per status. Severity: Failed is Warn (mail did not reach where it was sent, usually a bad address rather than a tenant outage); FilteredAsSpam / Quarantined / Resolved and everything else are Info, because the security stack working as designed fires constantly and rating it Warn would destroy the level's meaning | `exchange_online` | `POST outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand` running Get-MessageTraceV2 — read-only, no new grant (V1 Get-MessageTrace 400s; use V2). Paged by a KEYSET WALK, not a nextLink: a truncated response carries no @odata.nextLink at all, only an @adminapi.warnings string, and the continuation is (EndDate, StartingRecipientAddress) taken verbatim from the last record of the page — the warning prose is read as a boolean and never parsed. Proven lossless against an unpaged control read (9 pages, 37 of 37 ids, zero missed). The API re-serves duplicates even unpaged (44 rows for 37 distinct ids), so MessageTraceId dedupe is mandatory regardless of paging. Off unless `exchange_online.enabled` is set AND the collector is explicitly enabled | — | `high-volume opt-in` | 10m | 15m | `m365.message_trace` |
| `m365.unified_audit` | The M365 unified audit log, via the async query API: POST a query, poll it, page the result. Its records are not Entra's, so they land under a top-level `m365.audit` event name. The same signal as `m365.activity` over a different transport — NOT superseded by it. The two trade against each other: this one loses on transport (beta-only, a >10-minute async query, and it 429s on rapid query creation) and wins on volume control, because it sends server-side `recordTypeFilters` and can therefore take Teams while excluding the `DLPEndpoint` firehose — which `m365.activity`'s five content-type buckets cannot express. Worth nothing where log storage is free, decisive where it is billed per GB. The uncomfortable part: the cheaper path is the beta one. The include-list also covers the quarantine record types — a message held, released, previewed or deleted, plus quarantine-policy changes — which is low-volume and high-signal, and carries `network_message_id`, the join key onto `defender.email` / `defender.email_post_delivery` / `defender.email_url`, so a release event resolves to the message it released. Exactly one of the two may be enabled; registering both is refused at startup | `audit_query` | `POST /security/auditLog/queries` (beta — the documented v1.0 form 404s on a live tenant even under a token carrying the scope) | `AuditLogsQuery.Read.All` | `beta` | 30m | 1h | `m365.audit` |
| `m365.activity` | The same M365 unified audit records as `m365.unified_audit`, over the Office 365 Management Activity API instead: subscribe to a content type, list its content blobs, fetch each. Wins on transport — stable v1.0, 2,000 req/min per tenant, content ~2 minutes behind the event, and no async query — which is why this one is not Experimental. Loses on volume control: the API has NO server-side filtering, so `o365_activity.content_types` is the only knob and every record fetched is shipped. Defaults to Audit.Exchange + Audit.SharePoint; Audit.General is opt-in (it is the only route to Teams here, and it was 3,865 of 4,035 records Endpoint DLP on a 6-device tenant — the firehose `m365.unified_audit` can filter out server-side and this cannot), and Audit.AzureActiveDirectory is omitted because `entra.signins.interactive` and `entra.directory_audits` already emit those records. Exactly one of the two may be enabled; registering both is refused at startup | `o365_activity` | `manage.office.com/api/v1.0/{tenant}/activity/feed` — a second first-party API, NOT Graph: different audience, and `POST /subscriptions/start` is a write (the second break in graph2otel's read-only property, after the reports-export job) | `ActivityFeed.Read` | — | 10m | 5m | `m365.audit` |

## M365 — metrics (snapshot collectors)

| Collector | Collects | Intrinsic transport | Graph endpoint(s) | Required scope(s) | License / beta / volume | Interval | Metric namespace |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `m365.collaboration_activity` | Daily per-workload collaboration posture and adoption from the Graph usage reports (#362) — the existing audit feeds carry EVENTS, this carries the daily summary, which is what answers "is anyone actually using Teams/SharePoint/OneDrive, and who has gone quiet". Bounded gauges: m365.collaboration_activity.users{workload, activity_state} (9 series exactly; activity_state is derived here, and an EMPTY Last Activity Date means never_active — an absence of fact, never coerced to an epoch), .actions{workload, action} summed across users over a fixed per-workload action set, .users.deleted{workload}, and .shared_externally{workload} as its own series because external sharing is the alertable one and should not need a sum-by to reach. One m365.collaboration_activity_user twin per user per workload carries the UPN and the per-user counts — no per-user metric label anywhere, which is the exact violation that produced #110/#111. Assigned Products is emitted VERBATIM as one string: its own values contain ` + ` (`ENTERPRISE MOBILITY + SECURITY E5`), so any split produces fragments that are not products, and a fabricated license list is worse than an unparsed one | `graph` | `/reports/getTeamsUserActivityUserDetail(period='D7')`, `/reports/getSharePointActivityUserDetail(period='D7')`, `/reports/getOneDriveActivityUserDetail(period='D7')` — CSV, not JSON, each starting with a UTF-8 BOM that corrupts the first column name if not stripped (the package owns its own strip, matching the existing m365/storage and m365/mailboxusage precedent; there is no shared CSV package to import) | `Reports.Read.All` | runs on every tier; `Reports.Read.All`, already held. NOT Experimental (v1.0 GA) and NOT HighVolume — row count is one per licensed user, which scales with tenant SIZE rather than traffic, and #254 is explicit that conflating the two misleads an operator. Each of the three reports degrades independently, and a failed report contributes NOTHING rather than zero, because zero would misreport "unavailable" as "measured empty" (the #240 distinction). Report-name concealment was NOT observed live and is not claimed to be verified; the mapper never validates that a UPN parses as an email, so a concealed identifier is mapped and counted rather than dropped. Nothing is declared to internal/wirecheck: workload, activity_state and action are all derived by this collector from headers it controls, not from any Graph-supplied value set | 6h | `m365.collaboration_activity.actions{action,workload}`, `m365.collaboration_activity.shared_externally{workload}`, `m365.collaboration_activity.users{activity_state,workload}`, `m365.collaboration_activity.users.deleted{workload}`, plus a log twin per `m365.collaboration_activity_user` |
| `m365.mailbox_usage` | Exchange mailbox storage, quota state and inactivity (#359), built on the same usage-report transport m365.storage already hardened. Two bounded gauges: m365.mailbox_usage.storage_used_bytes (tenant-wide, one series, no per-entity label) and m365.mailbox_usage.quota_status.mailboxes{quota_state} over Microsoft's own fixed five-bucket vocabulary (under_limit / warning_issued / send_prohibited / send_receive_prohibited / indeterminate), zero-filled every cycle so a bucket emptying reads as an explicit 0 rather than a vanished series. One m365.mailbox_usage twin per mailbox carries what a metric must not: UPN, display name, item count, storage used, all three quota thresholds, deleted-item count/size/quota, last-activity date, archive presence, and a derived days_inactive. Two traps the mapper is written around. The two AGGREGATE reports return one row PER DAY of the period and re-serve the same history on every poll, so only the row with the most recent Report Date is emitted — otherwise the same days would be restated forever; rows are NOT assumed to arrive in date order. And an empty CSV cell is OMITTED, never coerced: Deleted Date is blank on a live mailbox and Last Activity Date is blank on one never used, so a fabricated zero or epoch would assert a deletion or an activity that never happened, and days_inactive is omitted entirely rather than read as inactive-forever. names_concealed is emitted only when /admin/reportSettings was actually readable — m365.storage can fall back to a data heuristic because Microsoft zeroes Site Id under concealment, but no analogous sentinel has been observed on this report's name columns, and a heuristic with no evidence behind it is worse than none | `graph` | `/reports/getMailboxUsageDetail`, `/reports/getMailboxUsageStorage`, `/reports/getMailboxUsageQuotaStatusMailboxCounts` (all `period='D7'`, RawGet follows the 302 to a CSV) plus `/admin/reportSettings`. `Reports.Read.All` for the reports, `ReportSettings.Read.All` for the concealment check — the latter is declared because the code calls it, but a failure there degrades to unknown-concealment rather than erroring | `Reports.Read.All`, `ReportSettings.Read.All` | — | 6h | `m365.mailbox_usage.quota_status.mailboxes{quota_state}`, `m365.mailbox_usage.storage_used_bytes`, plus a log twin per `m365.mailbox_usage` |
| `m365.servicehealth` | M365 service health, so "is this us or Microsoft?" is answerable in-band. From ONE `?$expand=issues` fetch: service count by health status, a numeric status enum per service (mapping in `docs/signals.md`), open-issue count by classification+status, and a log twin (`m365.service_health_issue`) per UNRESOLVED issue carrying id/title/impactDescription/service/timestamps — resolved history is covered by the aggregate counts, not re-twinned every cycle. Snapshot, not a window collector (no delta/time filter); `endDateTime` is null while open, so no duration is derived | `graph` | `/admin/serviceAnnouncement/healthOverviews?$expand=issues` | `ServiceHealth.Read.All` | — | 15m | `m365.service_health.issues.total{classification,status}`, `m365.service_health.services.total{status}`, `m365.service_health.status{service}`, plus a log twin per `m365.service_health_issue`, `m365.service_health_post` |
| `m365.servicemessages` | M365 message-center posts — the upcoming-change announcements (`planForChange`/`preventOrFixIssue`/`stayInformed`), a different question from service health. Bounded count by category+severity, plus a log twin (`m365.service_message`) per message carrying title/body/services/dates/`isMajorChange`/`actionRequiredByDateTime`. On by default; needs its own `ServiceMessage.Read.All` scope (the higher-volume half of the surface); a major change escalates the twin to Warn | `graph` | `/admin/serviceAnnouncement/messages` | `ServiceMessage.Read.All` | — | 1h | `m365.service_messages.total{category,severity}`, plus a log twin per `m365.service_message` |
| `m365.sharepoint_settings` | Tenant SharePoint/OneDrive sharing posture from one `/admin/sharepoint/settings` fetch: external-sharing capability + domain-restriction mode, legacy-auth toggle, external-resharing, unmanaged-sync restriction, idle-session sign-out, and default storage/retention limits — as bounded security-posture gauges, plus a log twin carrying the full config including the sharing domain allow/block lists (unbounded, so log-only per #112). Legacy-auth-on escalates the twin to Warn | `graph` | `/admin/sharepoint/settings` | `SharePointTenantSettings.Read.All` | — | 1h | `graph2otel.api.unexpected{collector,field,kind}`, `m365.sharepoint.deleted_user_retention_days`, `m365.sharepoint.external_resharing_enabled`, `m365.sharepoint.idle_session_signout_enabled`, `m365.sharepoint.legacy_auth_enabled`, `m365.sharepoint.personal_site_storage_limit_mb`, `m365.sharepoint.sharing{sharing_capability,sharing_domain_restriction_mode}`, `m365.sharepoint.sharing_allowed_domains`, `m365.sharepoint.sharing_blocked_domains`, `m365.sharepoint.site_storage_limit_mb`, `m365.sharepoint.unmanaged_sync_restricted`, plus a log twin per `m365.sharepoint_settings` |
| `m365.storage` | SharePoint + OneDrive storage utilization from the M365 usage-reporting API (`getSharePointSiteUsageStorage`/`getOneDriveUsageStorage` + the two `*Detail` functions, CSV via a 302). Tenant used/total bytes by drive type, and a drive count per derived quota state (`normal`/`nearing`/`critical`/`exceeded`, computed from used÷allocated — the live `quota.state` facet needs read-everything SharePoint scopes, so it is intentionally not used). One log twin per site/drive carries owner, site URL, byte counts, and quota state (unbounded, so log-only per #112). Reads `/admin/reportSettings` to warn when report name-concealment is on | `graph` | `/reports/getSharePointSiteUsageStorage`, `/reports/getOneDriveUsageStorage`, `/reports/getSharePointSiteUsageDetail`, `/reports/getOneDriveUsageAccountDetail` | `Reports.Read.All` | — | 6h | `m365.storage.drives.total{drive_type,quota_state}`, `m365.storage.total_bytes{drive_type}`, `m365.storage.used_bytes{drive_type}`, plus a log twin per `m365.drive_storage` |
| `m365.teams` | Microsoft Teams inventory governance: teams by visibility, the OWNERLESS count (zero owners = an unmanageable orphan holding files — the headline signal, excluding archived teams, which are a desired end-state), the WITH-GUESTS count (external-guest exposure = a data-egress surface), and tenant-wide membership by role. Plus one `m365.team` log twin per team (id, name, visibility, owner/member/guest counts, is_archived), Warn severity on an ownerless team. Also the Teams SECURITY surface (#247), riding the same per-team fan-out: installed apps by distribution_method × has_rsc_permissions (a `m365.teams_app` twin, Warn on a SIDELOADED app or an RSC grant — resource-specific consent that `entra.consent` structurally cannot see), and channels by membership_type × is_archived (a `m365.team_channel` twin; a SHARED channel exposes content to an external tenant no guest count can see). Three calls/team now (`?$select=summary,isArchived`, `installedApps`, `channels`) — installedApps rejects $top and is paged by nextLink. Long default interval; degrades to a skip-and-log 403 when the read scopes are not yet granted | `graph` | `/teams`, `/teams/{id}?$select=summary,isArchived`, `/beta/teams/{id}/installedApps`, `/beta/teams/{id}/channels` | `Team.ReadBasic.All`, `TeamSettings.Read.All`, `TeamsAppInstallation.Read.All`, `Channel.ReadBasic.All` | — | 1h | `m365.teams.channels.total{is_archived,membership_type}`, `m365.teams.installed_apps.total{distribution_method,has_rsc_permissions}`, `m365.teams.membership.total{role}`, `m365.teams.ownerless.total`, `m365.teams.total{visibility}`, `m365.teams.with_guests.total`, plus a log twin per `m365.team`, `m365.team_channel`, `m365.teams_app` |
| `m365.exchange_accepted_domains` | Which domains this Exchange organization accepts mail for, and how (#353) — the routing question that sits underneath every mail-flow decision. An Authoritative domain means Exchange Online holds every mailbox for it and rejects unknown recipients outright; an InternalRelay domain means unknown recipients are forwarded onward instead, which is legitimate in a hybrid deployment and is deliberately NOT flagged as a finding by this collector. Bounded gauge m365.exchange.accepted_domains{domain_type, is_default_domain}; one log twin per domain (m365.exchange_accepted_domain) carrying the domain name, the initial-domain and match-subdomains flags, DANE status, authentication type and the pending-removal state. The domain NAME is never a metric label — it is per-entity and grows with the number of domains a tenant owns, so it lives on the twin where a LogQL count answers the same question for free. DomainType is NOT wirecheck-watched: only Authoritative was observed live (4 of 4 domains, 2026-07-27), and one observed value is not a value set — a watchdog built on it would fire on the first legitimate InternalRelay domain, which is correct data | `exchange_online` | `POST outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand` running Get-AcceptedDomain with ResultSize=Unlimited — read-only. ResultSize is pinned because an unpinned EXO cmdlet truncates silently. Off unless `exchange_online.enabled` is set | — | — | 1h | `m365.exchange.accepted_domains{domain_type,is_default_domain}`, plus a log twin per `m365.exchange_accepted_domain` |
| `m365.exchange_audit_bypass` | Which mailboxes are exempted from mailbox audit logging (#356) — an audit-BLINDING control, so its healthy reading is zero and its interesting reading is any non-zero at all. A mailbox with bypass enabled produces no mailbox audit records at all, which means every downstream audit-based signal in this project is silently blind to it. Two bounded gauges, no per-mailbox label: m365.exchange.audit_bypass.enabled_count (associations with bypass ON, emitted every cycle so zero is stated rather than inferred from silence) and m365.exchange.audit_bypass.examined (associations actually classified this poll) — without the second, "nothing is bypassed" and "the cmdlet returned nothing" are the same series, and those are opposite facts. The log twin (m365.exchange_audit_bypass, Warn) is emitted ONLY for associations with bypass ENABLED, deliberately inverting this project's usual one-twin-per-entity default: the live population is 242 associations on a two-mailbox tenant, dominated by SystemMailbox/TenantSetting objects, so a twin per entity would be 242 records per cycle carrying no finding and would train an operator to ignore the event name. Here the twin IS the finding and its population is bounded by "someone deliberately turned this on", not by tenant size. AuditBypassEnabled decodes as a POINTER: a row missing the key is INDETERMINATE — excluded from both gauges and given no twin — because defaulting an absent key to false would report an unclassifiable mailbox as healthy, the exact inversion this collector exists to catch. RecipientTypeDetails is absent on every row live-measured and is not built on | `exchange_online` | `POST outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand` running Get-MailboxAuditBypassAssociation — read-only. The response also carries an @adminapi.warnings array about unrelated quota properties on two objects; that is EXO noise, not a collection failure. Off unless `exchange_online.enabled` is set | — | — | 1h | `m365.exchange.audit_bypass.enabled_count`, `m365.exchange.audit_bypass.examined`, plus a log twin per `m365.exchange_audit_bypass` |
| `m365.exchange_audit_config` | Exchange Online admin-audit-log configuration (#250) — corrects #99, which recorded that turning on the unified audit log is outside every API graph2otel touches: the READ half is now reachable. Bounded gauge m365.exchange.audit_config.enabled{setting} is a 0/1 flag per setting (unified_audit_log_ingestion, admin_audit_log); a log twin carries log level, age limit, first opt-in date and test-cmdlet logging. Warn when unified_audit_log_ingestion is OFF — the case that makes m365.activity and m365.unified_audit silently return nothing, indistinguishable from a quiet tenant. graph2otel reports it, never sets it | `exchange_online` | `POST outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand` running Get-AdminAuditLogConfig — read-only, authorized at Security Reader (unlike Get-OrganizationConfig). Same two grants as defender.quarantine. Off unless `exchange_online.enabled` is set | — | — | 1h | `m365.exchange.audit_config.enabled{setting}`, plus a log twin per `m365.exchange_audit_config` |
| `m365.exchange_connection_filter` | The connection-filter policy's IP allow and block lists (#358) — the crudest and bluntest mail-flow control there is, and the one most likely to have been widened during an incident and never narrowed again. Bounded gauges: m365.exchange.connection_filter.policies{is_default, enable_safe_list} counts the policy population, and ip_allow_list_length / ip_block_list_length carry the list sizes. A NON-EMPTY allow list is itself the finding — an allow-listed sender bypasses spam filtering entirely — so the length is emitted as a measured 0 on a healthy tenant rather than left absent, because a vanished series and a measured zero are opposite facts here. One m365.exchange_connection_filter_policy twin per policy carries the actual entries (capped at 100, with the true uncapped count alongside so a capped list still states its real size) plus enable_safe_list and directory_based_edge_block_mode. IP entries are per-entity and never a metric label. The length gauges are sourced from the DEFAULT policy only — a tenant can hold several policies, labeling a numeric gauge by policy identity would breach the cardinality rule, and aggregating several policies' list sizes into one number would be meaningless; every custom policy's full detail still reaches its own twin, which is where a LogQL query answers it. directory_based_edge_block_mode is NOT wirecheck-watched: only "Default" was observed live, and one observed value is not a value set | `exchange_online` | `POST outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand` running Get-HostedConnectionFilterPolicy — read-only. The cmdlet takes no ResultSize parameter (docs-verified 2026-07-28), so the usual silent-truncation pin does not apply. Off unless `exchange_online.enabled` is set | — | — | 1h | `m365.exchange.connection_filter.ip_allow_list_length`, `m365.exchange.connection_filter.ip_block_list_length`, `m365.exchange.connection_filter.policies{enable_safe_list,is_default}`, plus a log twin per `m365.exchange_connection_filter_policy` |
| `m365.exchange_connectors` | Exchange Online mail-flow connectors (#253) — the routing layer, which is the one piece of Exchange configuration that can redirect a tenant's mail wholesale. A rogue OUTBOUND connector sends tenant mail through attacker-controlled infrastructure; a rogue INBOUND one lets attacker mail arrive pre-trusted as if it came from the organization's own servers. Both are known M365 compromise patterns, both are invisible to every Graph endpoint, and neither is covered by m365.exchange_transport_rules — a rule acts on a message, a connector decides where messages travel. Bounded gauges m365.exchange.connectors{direction, enabled} with ALL FOUR series seeded (so "a connector appeared on a tenant that had none" is a change from 0, not a series materializing from nothing — you cannot alert on the latter) and .without_tls{direction}. One log twin per connector carrying the name, sender domains and IPs, the pinned certificate name, the restriction flags and the Enhanced Filtering skip-lists. Error on any ENABLED outbound connector and on an enabled inbound connector that does not require TLS; Warn on an enabled TLS-requiring inbound connector or a hand-created disabled one (ConnectorSource AdminUI is one toggle from live, while Default/HybridWizard is expected furniture). List fields are emitted VERBATIM including their priority suffixes — "smtp:*;1" is the wire value, not a domain and a number to parse. The two directions are DIFFERENT RECORD SHAPES rather than one shape with extras (live-measured 2026-07-24): an outbound record carries SmartHosts, RecipientDomains, UseMXRecord, RouteAllMessagesViaOnPremises, IsTransportRuleScoped, AllAcceptedDomains, SenderRewritingEnabled, TestMode, MtaStsMode, SmtpDaneMode and the validation fields, and carries NONE of RequireTls, the Sender*/Trusted*/EF* fields or TlsSenderCertificateName. So an outbound twin now says WHERE THE MAIL GOES, booleans are stamped only when the wire actually sent one (an absent field is never rendered as a confident false), and TLS is read per direction — the boolean RequireTls inbound, the TlsSettings enum outbound. TlsSettings' member names are deliberately not enumerated: exactly one value has been observed on the wire, so the collector tests only that a setting is present | `exchange_online` | `POST outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand` running Get-InboundConnector and Get-OutboundConnector — read-only, both cmdlets every cycle. Needs GLOBAL READER: both are 403 all-NUL at Security Reader alone (live-measured 2026-07-23). Off unless `exchange_online.enabled` is set | — | — | 1h | `m365.exchange.connectors{direction,enabled}`, `m365.exchange.connectors.without_tls{direction}`, plus a log twin per `m365.exchange_connector` |
| `m365.exchange_dkim` | Exchange Online DKIM signing posture per accepted domain (#250) — the outbound-authentication half `entra.domains` cannot tell (it sees SPF/DMARC records, not the signing config). Bounded gauge m365.exchange.dkim.signing{enabled, status} counts domains by signing enablement and configuration status; one log twin per domain (selectors, key sizes, algorithm, canonicalization, rotation/creation times). Warn when signing is enabled but Status is not Valid — mail is going out unsigned or with a failing selector, the actionable case; a domain with DKIM simply off is Info | `exchange_online` | `POST outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand` running Get-DkimSigningConfig — read-only. Same two grants as defender.quarantine (Exchange.ManageAsApp + Security Reader). Off unless `exchange_online.enabled` is set | — | — | 1h | `m365.exchange.dkim.signing{enabled,status}`, plus a log twin per `m365.exchange_dkim_config` |
| `m365.exchange_mailboxes` | Exchange Online mailbox census and posture (#250). Mailbox-level forwarding is the quietest exfiltration path in M365: ForwardingSmtpAddress copies every message out of the tenant, survives a password reset, and is invisible to the owner. Bounded gauges m365.exchange.mailboxes{recipient_type_details, forwarding_configured, audit_enabled} — keyed by mailbox TYPE so it grows with type VARIETY, never with mailbox count — and .setting_enabled{setting}, one seeded series per named boolean (audit_enabled, single_item_recovery, litigation_hold, retention_hold, compliance_tag_hold, hidden_from_address_lists, forwarding_configured, deliver_and_forward, archive_enabled, message_copy_sent_as, inactive_mailbox, account_disabled), so "how many mailboxes have single-item recovery OFF" is the total minus that series. One log twin per mailbox (UPN, addresses, forwarding targets, holds, quotas, archive state); Error on SMTP forwarding out of the tenant, Warn on internal forwarding or an unaudited mailbox. A UPN is NEVER a metric label (#112). ResultSize=Unlimited is load-bearing, not tuning: a truncated page arrives with an @odata.nextLink and an @adminapi.warnings string, the nextLink CANNOT be followed (its $skiptoken is bound to backend affinity this client cannot reproduce — live-measured 2026-07-23), and the cmdlet's own parameter is therefore the only way to defeat the page. Without it a large tenant would report a fraction of its mailboxes and look perfectly healthy. Quotas, LitigationHoldDuration ("Unlimited") and the .NET TimeSpans are emitted VERBATIM — re-deriving a number from a formatted string would invent a value the API never sent | `exchange_online` | `POST outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand` running Get-Mailbox -ResultSize Unlimited — read-only. Needs GLOBAL READER: 403 all-NUL at Security Reader alone (live-measured 2026-07-23). Off unless `exchange_online.enabled` is set | — | — | 1h | `m365.exchange.mailboxes{audit_enabled,forwarding_configured,recipient_type_details}`, `m365.exchange.mailboxes.setting_enabled{setting}`, plus a log twin per `m365.exchange_mailbox` |
| `m365.exchange_org_config` | Exchange Online organization configuration (#250) — the Get-OrganizationConfig half of the org-config pair (the Get-AdminAuditLogConfig half is m365.exchange_audit_config; they are separate collectors because they are separate cmdlets with separate authorization). Bounded gauge m365.exchange.org_config.setting_enabled{setting}: one 0/1 series per named boolean, keyed by the wire field's snake_case so POLARITY reads off the name (audit_disabled=1 means org-wide mailbox auditing is OFF). One log twin with the non-boolean configuration — EWS access policy, activity timeout interval, authentication policy, blocked-IP list. Error when OAuth2ClientProfileEnabled is false (modern auth OFF, so clients fall back to legacy protocols carrying no conditional-access or MFA evaluation), Warn when AuditDisabled is true. Two live coercion traps the mapper refuses: PublicFoldersEnabled is the STRING "Local" and never enters the 0/1 gauge, and settings arriving null (MessageRecallEnabled, FocusedInboxOn, DefaultAuthenticationPolicy) mean "not configured" — they are OMITTED rather than reported as 0, which would assert a decision the tenant never made | `exchange_online` | `POST outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand` running Get-OrganizationConfig — read-only. Needs GLOBAL READER: 403 all-NUL at Security Reader alone, unlike Get-AdminAuditLogConfig (live-measured 2026-07-23). Off unless `exchange_online.enabled` is set | — | — | 1h | `m365.exchange.org_config.setting_enabled{setting}`, plus a log twin per `m365.exchange_org_config` |
| `m365.exchange_outbound_spam` | Outbound spam policy posture (#357) — the controls that decide whether a compromised mailbox can quietly relay mail out of the tenant, and whether anyone is told when it tries. Bounded gauge m365.exchange.outbound_spam.policies{is_default, enabled}, plus m365.exchange.outbound_spam.recipient_limit{limit} over the three thresholds (external_per_hour / internal_per_hour / per_day). One m365.exchange_outbound_spam_policy twin per policy carries the rest: auto-forwarding mode, the action taken when the threshold is reached, whether suspicious outbound mail is BCC'd or notified, and the recipient lists for both (capped, with arrays_truncated set when they were). The limit gauges are sourced from the DEFAULT policy only, for the same reason as m365.exchange_connection_filter: several policies can exist, per-policy labels breach the cardinality rule, and a summed or averaged limit across policies is not a number that means anything — custom policies keep their full detail on their own twins. auto_forwarding_mode and action_when_threshold_reached are NOT wirecheck-watched: each was observed with exactly one live value, and a watchdog built on one observation fires on the first legitimate second one | `exchange_online` | `POST outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand` running Get-HostedOutboundSpamFilterPolicy — read-only. No ResultSize parameter exists on this cmdlet (docs-verified 2026-07-28). Off unless `exchange_online.enabled` is set | — | — | 1h | `m365.exchange.outbound_spam.policies{enabled,is_default}`, `m365.exchange.outbound_spam.recipient_limit{limit}`, plus a log twin per `m365.exchange_outbound_spam_policy` |
| `m365.exchange_remote_domains` | Exchange Online remote-domain policy (#250) — what the tenant does with mail addressed OUTSIDE it. AutoForwardEnabled is the tenant-wide switch for automatic external forwarding, the classic mailbox-rule exfiltration path that Microsoft's own hardening guidance says to turn off, and no Graph endpoint reports it. Bounded gauge m365.exchange.remote_domains{auto_forward_enabled}, both buckets seeded. One log twin per domain (OOF policy, reply/report/NDR flags, trusted-mail toggles, character sets). The entry whose domain is "*" covers EVERY remote domain, so forwarding enabled there is a tenant-wide hole and rates Error, while the same flag on a named partner domain is a narrower deliberate decision and rates Warn — collapsing the two would either cry wolf on every tenant with one exception or stay silent on a tenant that allows forwarding everywhere. m7kni's live wildcard entry has it ON. Tri-state fields (TNEFEnabled) arrive null meaning "use the default" and are omitted rather than asserted false | `exchange_online` | `POST outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand` running Get-RemoteDomain — read-only. Needs GLOBAL READER: 403 all-NUL at Security Reader alone (live-measured 2026-07-23). Off unless `exchange_online.enabled` is set | — | — | 1h | `m365.exchange.remote_domains{auto_forward_enabled}`, plus a log twin per `m365.exchange_remote_domain` |
| `m365.exchange_transport_rules` | Exchange Online mail-flow (transport) rules (#250) — a rule runs on every message in the tenant, and one that blind-copies or redirects mail to an outside address is textbook business-email-compromise persistence: it survives a password reset, the mailbox owner never sees it, and no Graph API exposes it. Bounded gauges m365.exchange.transport_rules{state, rule_mode} (2x3, both enums fixed by the API) and .redirecting{state}, the count of rules that blind-copy, copy, redirect or add a recipient. One log twin per rule with name, priority, author, last editor, the condition/action class names and every mail-diverting target; Error on an ENABLED diverting rule, Warn when the same rule is merely disabled (one toggle from live — an attacker staging a rule disabled is a real pattern) or when an enabled rule deletes messages. Live-measured 2026-07-23: Conditions/Actions/Exceptions are #Collection(String) of fully-qualified .NET class names under one constant namespace, which is stripped for readability, with anything outside it passed through verbatim | `exchange_online` | `POST outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand` running Get-TransportRule — read-only, authorized at Security Reader. Same two grants as defender.quarantine. Off unless `exchange_online.enabled` is set | — | — | 1h | `m365.exchange.transport_rules{rule_mode,state}`, `m365.exchange.transport_rules.redirecting{state}`, plus a log twin per `m365.exchange_transport_rule` |
| `m365.inbox_rules` | Current-state inventory of inbox rules on every mailbox in scope, INCLUDING hidden ones (#363) — malicious inbox rules are a classic post-compromise persistence and exfiltration mechanism, and unified audit reports rule CHANGES while this reports what is in place right now. Bounded gauge m365.inbox_rules.rules{enabled, has_forwarding, deletes}. Rule name, rule identity, mailbox address and forwarding targets are per-entity and never metric labels; they reach the m365.inbox_rule twin only. -IncludeHidden is pinned and load-bearing: live-measured, a mailbox whose only rule was the system Junk E-mail Rule returned 1 rule WITH the flag and 0 without, and attacker-planted rules with a manipulated rule-message-provider are hidden by construction. RuleIdentity is carried as a STRING, never a number: the wire type is BigInteger and a real observed value (15394007230974001153) EXCEEDS int64, while a float64 decode silently yields ...152 — an off-by-one id that still looks plausible. Identity embeds the same oversized integer and is string-only for the same reason. ForwardTo is NOT an address: it is a display name plus a legacyExchangeDN ("Rob Knight" [EX:/o=ExchangeLabs/...]) with no SMTP address anywhere in the rule object, so the raw value is emitted honestly rather than half-parsed into something that reads as an address, and external-vs-internal forwarding is NOT determinable from this cmdlet. RedirectTo, ForwardAsAttachmentTo, DeleteMessage=true and any genuinely maliciously-hidden rule are UNOBSERVED on the reference tenant: the fields are mapped but nothing is wirecheck-watched on a value set that has not been seen | `exchange_online` | `POST outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand` running Get-InboxRule -IncludeHidden, one call PER MAILBOX — read-only, and needing NO new Exchange RBAC role: the poller's existing Global Reader / Security Reader carry it, contradicting Microsoft's documentation (live-measured 2026-07-28, #363). Mutation cmdlets are provably denied — Exchange RBAC removes them from the session entirely, so New/Set/Remove/Disable-InboxRule fail CommandNotFoundException. 1+N with no batch form (995 ms per mailbox). Off unless BOTH `exchange_online.enabled` and `exo_per_mailbox.enabled` are set; the mailbox set and its cap come from the shared per-mailbox fan-out gate, which also supplies the covered/total gauges | — | — | 6h | `m365.inbox_rules.mailboxes_covered`, `m365.inbox_rules.mailboxes_total`, `m365.inbox_rules.rules{delete_message,enabled,has_forwarding}`, plus a log twin per `m365.inbox_rule` |
| `m365.mailbox_delegation` | Standing FullAccess and SendAs delegation on every mailbox in scope (#355) — the paths by which one principal can read or send as another, which the mailbox twin's GrantSendOnBehalfTo alone does not show. Bounded gauge m365.mailbox.delegation.assignments{kind, trustee_kind, is_inherited}: kind splits full_access from send_as, trustee_kind splits self from delegated. No mailbox or trustee identity ever reaches a metric label — that population scales with tenant size, so identities live on the log twin only (#112). One m365.mailbox_delegation twin per NON-SELF, NON-INHERITED assignment: that is the finding, and the implicit rows are inventory rather than findings. NT AUTHORITY\SELF (SID S-1-5-10) appears on EVERY mailbox and is not a delegation at all — it is retained and counted in its own gauge bucket but never elevated to a twin, and it is keyed on the SID rather than the display string, which is localizable. The same SELF SID occurs TWICE on one mailbox with different rights, so rows are never deduped by SID. AccessRights is a collection whose ELEMENTS are comma-joined strings ("FullAccess, ReadPermission" is one element carrying two rights), so rights are split within each element AND across elements; both the single and multi shapes occur live. PrimarySmtpAddress and TrusteeWithPrimarySmtpAddress are EMPTY even on genuine non-self delegations, so identity is read from User/Trustee and never from the SMTP field. Deny and IsInherited are NOT wirecheck-watched: every live row reads false, and one observed value is not a value set | `exchange_online` | `POST outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand` running Get-MailboxPermission and Get-RecipientPermission, one of each PER MAILBOX — read-only. Get-MailboxPermission returns HTTP 400 `Invalid Operation` without an Identity, so it cannot be called tenant-wide and this collector is inherently 1+2N with no batch form (live-measured 2026-07-28: 846 ms + 602 ms per mailbox). Off unless BOTH `exchange_online.enabled` and `exo_per_mailbox.enabled` are set; the mailbox set and its cap come from the shared per-mailbox fan-out gate, which also supplies the covered/total gauges | — | — | 6h | `m365.mailbox.delegation.assignments{is_inherited,kind,trustee_kind}`, `m365.mailbox.delegation.mailboxes_covered`, `m365.mailbox.delegation.mailboxes_total`, plus a log twin per `m365.mailbox_delegation` |

## Purview — metrics (snapshot collectors)

| Collector | Collects | Intrinsic transport | Graph endpoint(s) | Required scope(s) | License / beta / volume | Interval | Metric namespace |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `purview.dlp_policies` | DLP policy inventory + enforcement mode (#246): which policies exist, what mode each is in (audit vs Enforce — the "did someone quietly downgrade enforcement" signal), which workloads/actions each rule covers, when it last changed. Bounded gauges by enforcement_mode / workload×action / workload×binding_type + a single max-age gauge; log twins per policy (Warn when not Enforce) and per rule (SIT ids + confidence/count band). content is base64 of UTF-16LE XML; the version hash drives a parse-skip so the 76KB decode runs only on change. Emits policy DEFINITION only — a rule condition VALUE (matched content) is never emitted, guarded by test | `graph` | `/beta/security/dataSecurityAndGovernance/policyFiles` | `InformationProtectionPolicy.Read.All` | `beta` (beta/Experimental (opt-in); readable on scopes the poller already holds, no data-plane registration. A 403 is a graceful skip) | 1h | `purview.dlp.policies{enforcement_mode}`, `purview.dlp.policy.last_changed_age`, `purview.dlp.policy_bindings{binding_type,workload}`, `purview.dlp.rules{action,workload}`, plus a log twin per `purview.dlp_policy`, `purview.dlp_rule` |
| `purview.ediscovery_cases` | eDiscovery (Premium) case inventory: a bounded count of cases by status, plus a log twin per case (id, display name, custodial description, external id, real created/closed times). Opt-in and default-off — v1.0 GA, but a granted `eDiscovery.Read.All` scope is not enough: the app's service principal must also be registered in the Security & Compliance data plane (see `docs/data-plane-registration.md`), so a default deployment would 401 on every poll. The `0001-01-01` .NET-zero createdDateTime on the auto-created default case is dropped, not emitted as a year-0001 timestamp | `graph` | `/security/cases/ediscoveryCases` | `eDiscovery.Read.All` | `beta` | 1h | `purview.ediscovery.cases{status}`, plus a log twin per `purview.ediscovery_case` |
| `purview.ediscovery_case_health` | eDiscovery case CHILD-resource health, one level below `purview.ediscovery_cases` (#323): legal holds bucketed by enabled × has_errors, each hold's user/site data sources bucketed by source_type × hold_status, and long-running case operations bucketed by action × status; plus honest counts of custodians and non-custodial data sources, and cases_covered/cases_total so a capped cycle is visible rather than silent. Log twins per hold, per source and per operation carry the per-entity detail (case id, hold id, source id, mail address, site URL) that must never key a metric. Deliberately buckets holds on `isEnabled` + `len(errors)`, NOT on the hold's own `status` — that field is null on every healthy hold live, so bucketing on it would collapse a healthy tenant into `unknown` and report clean success over nothing. **No custodian field mapper is written, and that is a closed question rather than a deferral:** the unified eDiscovery experience (classic retired 2025-08-31) demoted custodians to a data-source type renamed "People" in the portal, and People attached to a hold policy materialize as `legalHolds/{id}/userSources` — verified across three attempts on two cases, each confirmed by `operations` gaining a holdPolicySync while `custodians` stayed at zero. `unifiedGroupSources` is never fetched per-hold (400 — the EDM declares it only on a custodian). The `0001-01-01` .NET-zero sentinel, live on `siteSource.site.createdDateTime`, is dropped rather than emitted as a year-0001 timestamp, and `createdBy.user` is resolved across the three inconsistent shapes the sibling routes return (on `legalHolds` the human name arrives in the `id` field) | `graph` | `/security/cases/ediscoveryCases/{id}/legalHolds` (+ `/userSources`, `/siteSources` per hold), `/custodians`, `/noncustodialDataSources`, `/operations` | `eDiscovery.Read.All` | `beta` (opt-in and default-off for the same reason as `purview.ediscovery_cases` — v1.0 GA, but `eDiscovery.Read.All` is not sufficient without the Security & Compliance data-plane registration (`docs/data-plane-registration.md`). Fan-out is `1 + 4C + 2H` with no batch form and no `$expand` over some of the slowest endpoints this project touches (an empty legalHolds list measured 11-22 s), so cases per cycle are capped at DefaultMaxCases with a covered/total gauge pair reporting any shortfall. Each child route degrades independently: a failed read emits nothing for that route (a gap, not a measured zero), and the HTTP 500 an unmaterialized case stub returns is a per-case skip, not a fatal error) | 1h | `graph2otel.api.unexpected{collector,field,kind}`, `purview.ediscovery.case_health.cases_covered`, `purview.ediscovery.case_health.cases_total`, `purview.ediscovery.case_operations{action,status}`, `purview.ediscovery.custodians`, `purview.ediscovery.hold_sources{hold_status,source_type}`, `purview.ediscovery.legal_holds{enabled,has_errors}`, `purview.ediscovery.noncustodial_data_sources`, plus a log twin per `purview.ediscovery_case_operation`, `purview.ediscovery_hold_source`, `purview.ediscovery_legal_hold` |
| `purview.retention_labels` | Retention label definitions + retention event types, each with a log twin. Blocked app-only on a live tenant — both endpoints 500 with `DataInsightsRequestError`/Forbidden even with the scope granted, because Microsoft documents Application access as not supported — so the collector recognizes that specific pair and reports unavailable rather than failing | `graph` | `/security/labels/retentionLabels`, `/security/triggerTypes/retentionEventTypes` | `RecordsManagement.Read.All` | `needs-license/purview_records_management` | 1h | `graph2otel.api.unexpected{collector,field,kind}`, `purview.retention.event_types.count`, `purview.retention.labels.count{action_after_retention,behavior_during_retention,retention_trigger}`, plus a log twin per `purview.retention_event_type`, `purview.retention_label` |
| `purview.sensitivity_labels` | Sensitivity label catalog: a count by applicable-to type, plus a log twin per label carrying its priority and `hasProtection` — which is how label encryption activation is readable at all. Bind the label's text to `name`: `displayName` is present but always null | `graph` | `/security/dataSecurityAndGovernance/sensitivityLabels` | `SensitivityLabel.Read` | `needs-license/purview_information_protection` | 1h | `graph2otel.api.unexpected{collector,field,kind}`, `purview.labels.count{applicable_to}`, plus a log twin per `purview.sensitivity_label` |

## Defender — logs (blob collectors)

| Collector | Collects | Intrinsic transport | Container (diagnostic category) | Cursor key | Required role | License / beta / volume | Interval | Log event |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `defender.alert_evidence` | One log per evidence row Defender attaches to an alert (`AlertEvidence`, absorbing #93) — the per-entity detail (real UPN/IP/geo/session/file) that `entra.security_alerts` collapses to a bare `evidence_count`. Joins to the alert on `alert_id`. On when blob ingest is configured | `blob` | `insights-logs-advancedhunting-alertevidence` (AdvancedHunting-AlertEvidence) | `insights-logs-advancedhunting-alertevidence` | `Storage Blob Data Reader` | — | 15m | `defender.alert_evidence` |
| `defender.alert_info` | One log per Defender XDR alert header (`AlertInfo`) — the alert's title, category (MITRE tactic), severity, detection/service source, and attack techniques, keyed by `alert_id`. The alert-level companion to `defender.alert_evidence`'s per-entity rows: join the two on `alert_id`. On when blob ingest is configured | `blob` | `insights-logs-advancedhunting-alertinfo` (AdvancedHunting-AlertInfo) | `insights-logs-advancedhunting-alertinfo` | `Storage Blob Data Reader` | — | 15m | `defender.alert_info` |
| `defender.behavior_entity` | One log per entity involved in a Defender behavior (`BehaviorEntities`, #241) — the ~37-column per-entity detail (file/SHA, remote IP/URL, account, device, email, process, registry) for a scored behavior, the same shape family as `AlertEvidence`. Joins to `defender.behavior` on `behavior_id`. On when blob ingest is configured (was streaming, billed and deleted unread until #241) | `blob` | `insights-logs-advancedhunting-behaviorentities` (AdvancedHunting-BehaviorEntities) | `insights-logs-advancedhunting-behaviorentities` | `Storage Blob Data Reader` | — | 15m | `defender.behavior_entity` |
| `defender.behavior` | One log per Defender BEHAVIOR — the sub-alert "suspicious pattern observed and scored" tier that sits between raw EDR telemetry and a fired alert (`BehaviorInfo`, #241): BehaviorId, ActionType, MITRE Categories/AttackTechniques, ServiceSource, the prose Description, and the involved account/device. Everything else graph2otel ships starts at the fired alert; a SIEM wants both tiers. Joins to `defender.behavior_entity` on `behavior_id`. On when blob ingest is configured (was streaming, billed and deleted unread until #241) | `blob` | `insights-logs-advancedhunting-behaviorinfo` (AdvancedHunting-BehaviorInfo) | `insights-logs-advancedhunting-behaviorinfo` | `Storage Blob Data Reader` | — | 15m | `defender.behavior` |
| `defender.cloud_app_event` | One log per cloud-app activity Defender for Cloud Apps records (`CloudAppEvents`) — SharePoint/Exchange/OAuth file ops, ACL changes, mail access, sign-ins — with actor/app/object, IP+geo, admin/external/impersonation flags, and the raw event payload (`raw_event_data`). On when blob ingest is configured | `blob` | `insights-logs-advancedhunting-cloudappevents` (AdvancedHunting-CloudAppEvents) | `insights-logs-advancedhunting-cloudappevents` | `Storage Blob Data Reader` | — | 15m | `defender.cloud_app_event` |
| `defender.device_event` | One log per miscellaneous endpoint event Defender records (`DeviceEvents`) — the catch-all table spanning AMSI/`ScriptContent`, memory-injection API calls, USB mounts, WMI process creation, and more, keyed by `action_type`. `ScriptContent` (the full script body, inside `additional_fields`) ships verbatim per #106. On when blob ingest is configured | `blob` | `insights-logs-advancedhunting-deviceevents` (AdvancedHunting-DeviceEvents) | `insights-logs-advancedhunting-deviceevents` | `Storage Blob Data Reader` | — | 15m | `defender.device_event` |
| `defender.device_file` | One log per file create/modify/rename/delete Defender for Endpoint observes (`DeviceFileEvents`) — file hashes, paths, origin URL/IP, share and sensitivity-label context, with the initiating process. On when blob ingest is configured | `blob` | `insights-logs-advancedhunting-devicefileevents` (AdvancedHunting-DeviceFileEvents) | `insights-logs-advancedhunting-devicefileevents` | `Storage Blob Data Reader` | — | 15m | `defender.device_file` |
| `defender.device_file_certificate` | One log per file code-signing certificate Defender observes (`DeviceFileCertificateInfo`) — signer/issuer + hashes, signature type, serial, validity window, CRL URLs, and trust/root-Microsoft flags. Companion to `device_file`/`device_process` for signing-trust hunting. Snapshot; on when blob ingest is configured | `blob` | `insights-logs-advancedhunting-devicefilecertificateinfo` (AdvancedHunting-DeviceFileCertificateInfo) | `insights-logs-advancedhunting-devicefilecertificateinfo` | `Storage Blob Data Reader` | — | 15m | `defender.device_file_certificate` |
| `defender.device_image_load` | One log per image (DLL/module) load Defender for Endpoint observes (`DeviceImageLoadEvents`) — the DLL-side-load hunting signal, with the loaded file's hashes/path and the full initiating-process lineage. On when blob ingest is configured | `blob` | `insights-logs-advancedhunting-deviceimageloadevents` (AdvancedHunting-DeviceImageLoadEvents) | `insights-logs-advancedhunting-deviceimageloadevents` | `Storage Blob Data Reader` | — | 15m | `defender.device_image_load` |
| `defender.device_info` | One log per periodic device-inventory snapshot from Defender for Endpoint (`DeviceInfo`) — OS, onboarding, exposure level, sensor health, and cloud-hosting metadata not in Graph. Snapshot-shaped (no ActionType), so it re-emits per cycle. On when blob ingest is configured | `blob` | `insights-logs-advancedhunting-deviceinfo` (AdvancedHunting-DeviceInfo) | `insights-logs-advancedhunting-deviceinfo` | `Storage Blob Data Reader` | — | 15m | `defender.device_info` |
| `defender.device_logon` | One log per interactive/network/service logon Defender for Endpoint observes (`DeviceLogonEvents`) — the local and non-Entra logons Entra sign-in logs never see, with the initiating process, remote IP, and logon type. On when blob ingest is configured | `blob` | `insights-logs-advancedhunting-devicelogonevents` (AdvancedHunting-DeviceLogonEvents) | `insights-logs-advancedhunting-devicelogonevents` | `Storage Blob Data Reader` | — | 15m | `defender.device_logon` |
| `defender.device_network` | One log per network connection Defender for Endpoint observes (`DeviceNetworkEvents`) — local/remote IP+port, URL, protocol, with the initiating process; the C2/exfil/lateral-movement signal. On when blob ingest is configured | `blob` | `insights-logs-advancedhunting-devicenetworkevents` (AdvancedHunting-DeviceNetworkEvents) | `insights-logs-advancedhunting-devicenetworkevents` | `Storage Blob Data Reader` | — | 15m | `defender.device_network` |
| `defender.device_network_info` | One log per device network-adapter snapshot (`DeviceNetworkInfo`) — MAC, adapter name/type/status/vendor, DHCP flags, tunnel type, and the (stringified) IP/DNS/gateway/connected-network lists. Enrichment companion to `device_network`. Snapshot; on when blob ingest is configured | `blob` | `insights-logs-advancedhunting-devicenetworkinfo` (AdvancedHunting-DeviceNetworkInfo) | `insights-logs-advancedhunting-devicenetworkinfo` | `Storage Blob Data Reader` | — | 15m | `defender.device_network_info` |
| `defender.device_process` | One log per process creation Defender for Endpoint observes (`DeviceProcessEvents`) — the process tree (created process + full initiating-process lineage, command lines, hashes, signer) that is the core of endpoint hunting. The largest-volume Defender table; on when blob ingest is configured | `blob` | `insights-logs-advancedhunting-deviceprocessevents` (AdvancedHunting-DeviceProcessEvents) | `insights-logs-advancedhunting-deviceprocessevents` | `Storage Blob Data Reader` | — | 15m | `defender.device_process` |
| `defender.device_registry` | One log per Windows registry create/set/delete Defender for Endpoint observes (`DeviceRegistryEvents`) — a primary persistence-hunting signal (Run keys, service installs, policy tampering) Graph exposes nowhere. Each record pairs the registry change with the full InitiatingProcess block, so a LogQL join answers which process wrote a key. The highest-volume Defender surface; on when blob ingest is configured | `blob` | `insights-logs-advancedhunting-deviceregistryevents` (AdvancedHunting-DeviceRegistryEvents) | `insights-logs-advancedhunting-deviceregistryevents` | `Storage Blob Data Reader` | — | 15m | `defender.device_registry` |
| `defender.email` | One log per message Defender for Office 365 processes (`EmailEvents`) — sender/recipient, delivery action, threat verdicts, and authentication results; zero MDO email coverage exists today. On when blob ingest is configured | `blob` | `insights-logs-advancedhunting-emailevents` (AdvancedHunting-EmailEvents) | `insights-logs-advancedhunting-emailevents` | `Storage Blob Data Reader` | — | 15m | `defender.email` |
| `defender.email_attachment` | One log per email attachment (`EmailAttachmentInfo`) — file name/type/extension/size, `sha256` (malware-hash hunting), detection methods and threat verdicts, sender/recipient — joined to `defender.email` on `network_message_id`. On when blob ingest is configured | `blob` | `insights-logs-advancedhunting-emailattachmentinfo` (AdvancedHunting-EmailAttachmentInfo) | `insights-logs-advancedhunting-emailattachmentinfo` | `Storage Blob Data Reader` | — | 15m | `defender.email_attachment` |
| `defender.email_post_delivery` | One log per post-delivery action Defender for Office 365 takes on an already-delivered message (`EmailPostDeliveryEvents`) — ZAP, manual and automated remediation, and redelivery — with the action, its trigger and result, and the resulting `delivery_location`; the only signal that shows a message MOVING into or out of quarantine, joined to `defender.email` on `network_message_id`. On when blob ingest is configured | `blob` | `insights-logs-advancedhunting-emailpostdeliveryevents` (AdvancedHunting-EmailPostDeliveryEvents) | `insights-logs-advancedhunting-emailpostdeliveryevents` | `Storage Blob Data Reader` | — | 15m | `defender.email_post_delivery` |
| `defender.email_url` | One log per URL found in a message (`EmailUrlInfo`) — the URL, its domain, and position in the redirect chain, joined to `defender.email` on `network_message_id`. On when blob ingest is configured | `blob` | `insights-logs-advancedhunting-emailurlinfo` (AdvancedHunting-EmailUrlInfo) | `insights-logs-advancedhunting-emailurlinfo` | `Storage Blob Data Reader` | — | 15m | `defender.email_url` |
| `defender.identity_info` | One log per identity snapshot (`IdentityInfo`) — the enrichment Graph doesn't expose: `criticality_level`, `blast_radius`, `privileged_entra_pim_roles`, risk level, plus directory attributes (department/title/manager/on-prem+cloud SIDs). Snapshot; on when blob ingest is configured | `blob` | `insights-logs-advancedhunting-identityinfo` (AdvancedHunting-IdentityInfo) | `insights-logs-advancedhunting-identityinfo` | `Storage Blob Data Reader` | — | 15m | `defender.identity_info` |
| `defender.identity_logon` | One log per identity logon Defender for Identity observes (`IdentityLogonEvents`) — on-prem/hybrid AD + cloud logons Entra sign-in logs never see, with account/target/destination, logon type, IP + geo/ISP, and the raw `additional_fields`. On when blob ingest is configured | `blob` | `insights-logs-advancedhunting-identitylogonevents` (AdvancedHunting-IdentityLogonEvents) | `insights-logs-advancedhunting-identitylogonevents` | `Storage Blob Data Reader` | — | 15m | `defender.identity_logon` |
| `defender.teams_message` | One log per Teams message Defender for Office 365 protects (`MessageEvents`, #241) — sender identity/type, thread/group, IsExternalThread, threat verdicts, delivery action/location. graph2otel had zero Teams SECURITY coverage (`m365.teams` is inventory governance only); IsExternalThread + SenderType is the external-collaboration attack surface. Joins to `defender.teams_message_url` on `teams_message_id`. On when blob ingest is configured (was streaming, billed and deleted unread until #241) | `blob` | `insights-logs-advancedhunting-messageevents` (AdvancedHunting-MessageEvents) | `insights-logs-advancedhunting-messageevents` | `Storage Blob Data Reader` | — | 15m | `defender.teams_message` |
| `defender.teams_message_url` | One log per URL inside a Teams message (`MessageUrlInfo`, #241) — the URL and its domain, keyed by `teams_message_id` (the join into `defender.teams_message`), `report_id`. On when blob ingest is configured (was streaming, billed and deleted unread until #241) | `blob` | `insights-logs-advancedhunting-messageurlinfo` (AdvancedHunting-MessageUrlInfo) | `insights-logs-advancedhunting-messageurlinfo` | `Storage Blob Data Reader` | — | 15m | `defender.teams_message_url` |
| `defender.url_click_event` | One log per Safe Links URL click Defender for Office 365 records (`UrlClickEvents`) — the clicked URL and its chain, the click verdict/action, whether the user clicked through a block, threat types, and the app/workload context, keyed by `network_message_id`. Zero coverage exists today. On when blob ingest is configured | `blob` | `insights-logs-advancedhunting-urlclickevents` (AdvancedHunting-UrlClickEvents) | `insights-logs-advancedhunting-urlclickevents` | `Storage Blob Data Reader` | — | 15m | `defender.url_click_event` |

## Defender — metrics (snapshot collectors)

| Collector | Collects | Intrinsic transport | Graph endpoint(s) | Required scope(s) | License / beta / volume | Interval | Metric namespace |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `defender.allow_block_list` | The Tenant Allow/Block List (#250) — the tenant-managed entries that let mail bypass, or be killed by, Defender for Office 365. An allow entry is a standing hole past mail security and creating one is a documented attacker persistence technique; no Graph API and no other transport exposes it. Bounded gauges: defender.allow_block_list.entries{list_type, action, list_subtype} (at most 4x2x2), .non_expiring_allow{list_type} — the headline signal, an allow with NEITHER an expiration date NOR a remove-after-last-use window, so nothing will ever remove it — .expiring_soon{list_type, action} (7-day horizon; an expiring BLOCK silently restores delivery from a sender blocked for a reason), and .spoof_entries{spoof_type, action}. One log twin per entry carrying the listed VALUE, notes, who set it and when, and a bounded expiry_bucket; Error on a non-expiring allow, and on any spoof allow (spoof overrides carry no expiry mechanism at all, so they are permanent by construction). Live-measured 2026-07-23: the three expiry shapes are real and distinct, and null ExpirationDate does NOT imply permanent — RemoveAfter may still bound it | `exchange_online` | `POST outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand` running Get-TenantAllowBlockListItems once per ListType (Sender, Url, FileHash, IP) plus Get-TenantAllowBlockListSpoofItems — five read-only calls. Same two grants as defender.quarantine (Exchange.ManageAsApp + Security Reader); no Global Reader needed. Off unless `exchange_online.enabled` is set | — | — | 1h | `defender.allow_block_list.entries{action,list_subtype,list_type}`, `defender.allow_block_list.expiring_soon{action,list_type}`, `defender.allow_block_list.non_expiring_allow{list_type}`, `defender.allow_block_list.spoof_entries{action,spoof_type}`, plus a log twin per `defender.allow_block_list` |
| `defender.mdo_policies` | Microsoft Defender for Office 365 policy posture (#250) — what each verdict class actually DOES, which no Graph API exposes. Bounded gauges: defender.mdo.policies{policy_type} (count per type across HostedContentFilter/MalwareFilter/AntiPhish/SafeLinks/SafeAttachment/AtpForO365/TeamsProtection) and defender.mdo.protection_enabled{policy_type, protection} (count of policies with a named boolean protection on — zap, spoof_intelligence, safe_docs, safe_links_email, …; a capable-but-off protection emits a 0-valued series, distinct from a protection the type does not have). One log twin per policy carrying every verdict action, threshold and ZAP toggle (spam_action, high_confidence_phish_action, dmarc_reject_action, phish_threshold_level, …). Warn when a policy that supports ZAP has it switched off (a wire-visible weakening); everything else Info | `exchange_online` | `POST outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand` running Get-HostedContentFilterPolicy, Get-MalwareFilterPolicy, Get-AntiPhishPolicy, Get-SafeLinksPolicy, Get-SafeAttachmentPolicy, Get-AtpPolicyForO365, Get-TeamsProtectionPolicy — one Invoke per cmdlet, all read-only. Same two grants as defender.quarantine (Exchange.ManageAsApp + Security Reader). Off unless `exchange_online.enabled` is set | — | — | 1h | `defender.mdo.policies{policy_type}`, `defender.mdo.protection_enabled{policy_type,protection}`, plus a log twin per `defender.mdo_policy` |
| `defender.mdo_rules` | The MDO/EOP protection-policy RULE layer (#354) — WHO each policy reaches and in what precedence order, which `defender.mdo_policies` structurally cannot answer: a policy says what protection is applied, a rule says to whom. Bounded gauges: defender.mdo.rules{rule_family, state} over the ten rule families, with every family x state series seeded so "a rule appeared on a tenant that had none" is a change from 0 rather than a series materializing from nothing; .rules.conditions{rule_family, condition} counting rules carrying each of the six fixed recipient conditions; .rules.unscoped{rule_family}, the enabled rules with NO recipient condition at all, which therefore apply org-wide; and .unreferenced_policies{policy_type}, a policy that exists but that no enabled rule applies — configured-but-inert, the exact shape of a tenant that believes it is protected and is not. Log twins defender.mdo_rule (per rule: name, identity, priority, state, the referenced policy names, and every recipient/group/domain include and exclude list) and defender.mdo_unreferenced_policy (per inert policy). Recipient addresses, group names and domains are twin-only — each grows with the tenant, so none is ever a metric label (#112). A NULL condition on the wire means the axis is UNSCOPED, not empty, so the attribute is omitted rather than emitted as an empty list; conflating the two would read "applies to everyone" as "applies to nobody". Warn on a DISABLED rule (the policy is configured and not being applied) and on an unreferenced policy; an enabled unscoped rule is Info, because a preset security policy covering the whole tenant is the recommended configuration, not a finding. Live-measured 2026-07-28: all ten cmdlets AUTHORIZED under the poller's existing grants, three returning one rule each and seven returning zero — authorized-and-empty, which is a m7kni configuration fact (preset policies, no custom rules), NOT a permission gap. Nothing is declared to internal/wirecheck: State was observed at exactly one value (Enabled) and rule_family/condition are graph2otel-derived names, not Graph-supplied value sets | `exchange_online` | `POST outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand` running Get-ATPProtectionPolicyRule, Get-EOPProtectionPolicyRule, Get-ATPBuiltInProtectionRule, Get-HostedContentFilterRule, Get-MalwareFilterRule, Get-AntiPhishRule, Get-SafeLinksRule, Get-SafeAttachmentRule, Get-TeamsProtectionPolicyRule and Get-HostedOutboundSpamFilterRule for the rules, plus the seven Get-*Policy cmdlets defender.mdo_policies already reads, to compute the unreferenced-policy join. Seventeen read-only Invokes per hourly cycle. The seven-cmdlet overlap with defender.mdo_policies is deliberate: an orphaned policy is visible ONLY from both sides at once, and re-reading a cheap cmdlet costs less than a cross-collector coupling. Same two grants as defender.quarantine (Exchange.ManageAsApp + Security Reader), no new scope. Off unless `exchange_online.enabled` is set | — | — | 1h | `defender.mdo.rules{rule_family,state}`, `defender.mdo.rules.conditions{condition,rule_family}`, `defender.mdo.rules.unscoped{rule_family}`, `defender.mdo.unreferenced_policies{policy_type}`, plus a log twin per `defender.mdo_rule`, `defender.mdo_unreferenced_policy` |
| `defender.quarantine` | How many messages are HELD in Defender for Office 365 quarantine right now — a bounded gauge by `quarantine_type` x `direction` x `entity_type`, plus one log per held message carrying sender, recipients, subject, the quarantine policy and tag, expiry, and the per-message permission flags (`permission_to_release=false` means the recipient cannot self-release). Queue DEPTH, not flow: released messages stay visible to the API for the rest of their retention and are deliberately not counted, so the number returns to zero when quarantine drains. The state third of graph2otel's quarantine coverage — `defender.email_post_delivery` carries the movement and `m365.unified_audit` the history, and all three join on `network_message_id`. No Graph endpoint exists for any of this. Off unless `exchange_online.enabled` is set | `exchange_online` | `POST outlook.office365.com/adminapi/beta/{tenant}/InvokeCommand` running `Get-QuarantineMessage -ReleaseStatus NOTRELEASED` — a fourth first-party API, NOT Graph. It needs TWO grants and neither alone does anything (live-measured: 401 with neither, 403 with the app role only, 200 with both): the app role `Exchange.ManageAsApp` on Office 365 Exchange Online, plus an Entra DIRECTORY role on the service principal, `Security Reader` being the least-privileged sufficient one. Both read-only. Quarantined TEAMS messages need `-EntityType Teams`, which `Security Reader` is denied, so they are covered via the audit trail instead | — | — | 15m | `defender.quarantine.held_messages.total{direction,entity_type,quarantine_type}`, `graph2otel.api.unexpected{collector,field,kind}`, plus a log twin per `defender.quarantine` |
| `defender.exposure_graph` | The Defender Exposure Management graph, deliberately bounded (#350) — the identities, devices, cloud resources and AI agents Microsoft has linked together, and the relationships between them. Bounded gauges: defender.exposure_graph.nodes{node_label, twinned} and .edges{edge_label, twinned}, both keyed by Microsoft's own ontology (19 node labels / 13 edge labels live, bounded by the ontology rather than by tenant size), plus .excluded_labels, the count of labels too large to twin. Log twins defender.exposure_graph_node and defender.exposure_graph_edge carry the per-entity detail. THE BOUNDING RULE FALLS OUT OF THE DATA, not out of a guess: live-measured 2026-07-28, the tenant holds 993,292 nodes of which `mdcSecurityRecommendation` (618,032) and `Cve` (372,330) are 99.7%, while the next-largest label is 1,057 — so a per-label twin threshold sitting in that 350x empty band separates bulk inventory from the ~2,932 nodes an operator would actually investigate, on any tenant, with no hardcoded label names and no sampling. An over-threshold label is COUNTED and reported as twinned=false, never silently truncated. NodeProperties is a per-label-shaped `dynamicColumnValue` and is never emitted raw; the twin carries the observed field union with the label as discriminator, so an unseen label still produces a record (acceptance: unknown labels do not drop rows). EntityIds arrive as JSON STRINGS needing a second decode — treating an element as an identifier ships `{"type":"AiModelNameAndVersion","id":"…"}` where an id belongs. Finding descriptions are multi-paragraph HTML and are deliberately not emitted. Warn on a node whose properties report leaked credentials; everything else Info. NOT a graph analysis: no attack paths, choke points or centrality are derived from raw nodes and edges — a label census is a census | `graph` | `POST /security/runHuntingQuery` (v1.0, read-only KQL) over ExposureGraphNodes and ExposureGraphEdges. Four queries per cycle: a node census, a node twin fetch excluding the over-threshold labels, a pruned edge census, and an edge twin fetch. Every edge query carries a `where` predicate that prunes BEFORE aggregating, because it must: `ExposureGraphEdges \| summarize count()` unpruned is a live-measured HTTP 504 at 150s, so the edge table cannot be counted at all whole, while the same aggregation pruned to non-bulk endpoints returns in 72s. Long default interval (6h) — the advanced-hunting CPU budget is shared with humans in the Defender portal (#106). Needs `ThreatHunting.Read.All`, already held; no new grant. NOT Experimental: the transport is Graph v1.0, and #183 reserves that gate for genuine Graph beta surfaces. Off unless `hunting.enabled` is set | `ThreatHunting.Read.All` | — | 6h | `defender.exposure_graph.edges{edge_label,twinned}`, `defender.exposure_graph.excluded_labels{entity_type}`, `defender.exposure_graph.nodes{node_label,twinned}`, plus a log twin per `defender.exposure_graph_edge`, `defender.exposure_graph_node` |
| `defender.oauth_app` | Defender for Cloud Apps OAuth-app inventory (#252) — every consented OAuth app with the risk signals Graph does not compute: a per-app `risk_score`, a `privilege_level`, a `verified_publisher` record and `last_used_time`. Bounded gauges: defender.oauth_app.apps{privilege_level, app_status, app_origin, admin_consented}, .max_risk_score{privilege_level} (RiskScore is a 0-100 int — twin-only, never a label, no invented bands) and .consented_users{privilege_level}. One log twin per app carrying the ids, risk score, verified-publisher name/id, consented-user count, last-used/added times, and the granted permission values (parsed from the embedded-JSON Permissions list). Warn on a High-privilege app (Microsoft's own classification, verbatim). Reachable as an advanced-hunting table WITHOUT the MDCA portal token; complements entra.consent (the grant) with Defender's scoring. Off unless `hunting.enabled` is set | `graph` | `POST /security/runHuntingQuery` (v1.0, read-only KQL) over OAuthAppInfo. Long default interval (6h) for the shared advanced-hunting CPU budget (#106); twin fetch partitions under the 100,000-row cap. Needs `ThreatHunting.Read.All` | `ThreatHunting.Read.All` | — | 6h | `defender.oauth_app.apps{admin_consented,app_origin,app_status,privilege_level}`, `defender.oauth_app.consented_users{privilege_level}`, `defender.oauth_app.max_risk_score{privilege_level}`, plus a log twin per `defender.oauth_app` |
| `defender.secure_config` | Device secure-configuration assessments (#249) — which security configurations each onboarded device passes or fails, and the impact of the gaps. Bounded gauges: defender.secure_config.assessments{configuration_category, is_compliant} (applicable assessments, 5 categories x compliance), .noncompliant_devices{configuration_category} and .impact_at_risk{configuration_category} (summed impact of failures). One log twin per applicable (device, configuration) assessment — both compliance states, per #114 — carrying device, category/subcategory, configuration id and impact. Warn when the device fails the configuration. No Graph endpoint; hunting-only. Off unless `hunting.enabled` is set | `graph` | `POST /security/runHuntingQuery` (v1.0, read-only KQL) over DeviceTvmSecureConfigurationAssessment. Long default interval (6h) for the shared advanced-hunting CPU budget (#106); twin fetch partitions under the 100,000-row cap. Needs `ThreatHunting.Read.All` | `ThreatHunting.Read.All` | — | 6h | `defender.secure_config.assessments{configuration_category,is_compliant}`, `defender.secure_config.impact_at_risk{configuration_category}`, `defender.secure_config.noncompliant_devices{configuration_category}`, plus a log twin per `defender.secure_config` |
| `defender.software_inventory` | Device software inventory (#249) — what software is installed on which onboarded device and its end-of-support status. Bounded gauges: defender.software.installs / .eos_devices / .products, each keyed by `end_of_support_status` (the empty string is a REAL bucket — supported software). One log twin per (device, product, version) install carrying vendor/name/version, CPE and EOS status/date. Warn on any end-of-support status. Complements intune.detected_apps, which carries no CPE, no EOS status, and only Intune-managed devices. No Graph endpoint; hunting-only. Off unless `hunting.enabled` is set | `graph` | `POST /security/runHuntingQuery` (v1.0, read-only KQL) over DeviceTvmSoftwareInventory. Long default interval (6h) for the shared advanced-hunting CPU budget (#106); twin fetch partitions under the 100,000-row cap. Needs `ThreatHunting.Read.All` | `ThreatHunting.Read.All` | — | 6h | `defender.software.eos_devices{end_of_support_status}`, `defender.software.installs{end_of_support_status}`, `defender.software.products{end_of_support_status}`, plus a log twin per `defender.software` |
| `defender.vulnerabilities` | Device software vulnerabilities (#249) — which CVEs are present on which onboarded devices, joined server-side to the KB table for CVSS, EPSS and exploit availability. Bounded gauges from one summarize: defender.vulnerability.instances / .affected_devices / .cves / .max_cvss / .max_epss, each keyed by `severity` x `exploit_available`. One log twin per (device, CVE, software) row carrying device, OS, software vendor/name/version, CVE, CVSS/EPSS, exploit flag, recommended update and CVE mitigation status — turning 24,912 instances into a ranked patch worklist. Error on Critical or exploit-available. No Graph REST endpoint and no Defender blob container exists for TVM; hunting-only. Off unless `hunting.enabled` is set | `graph` | `POST /security/runHuntingQuery` (v1.0, read-only KQL) over DeviceTvmSoftwareVulnerabilities joined to DeviceTvmSoftwareVulnerabilitiesKB. Long default interval (6h) — the advanced-hunting CPU budget is shared with humans in the Defender portal (#106). Twin fetch partitions under the hard 100,000-row-per-query cap. Needs `ThreatHunting.Read.All` | `ThreatHunting.Read.All` | — | 6h | `defender.vulnerability.affected_devices{exploit_available,severity}`, `defender.vulnerability.cves{exploit_available,severity}`, `defender.vulnerability.instances{exploit_available,severity}`, `defender.vulnerability.max_cvss{exploit_available,severity}`, `defender.vulnerability.max_epss{exploit_available,severity}`, plus a log twin per `defender.vulnerability` |

## Defender for Cloud Apps — metrics (snapshot collectors)

| Collector | Collects | Intrinsic transport | Graph endpoint(s) | Required scope(s) | License / beta / volume | Interval | Metric namespace |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `mdca.cloud_discovery` | The shadow-IT cloud apps Defender for Cloud Apps has DISCOVERED from uploaded traffic logs, plus the health of the streams feeding that discovery (#361). Distinct from both neighbors: mdca.discovery_parse reports whether log PARSING works and inventories no app, and Defender's OAuth posture covers CONSENTED apps, a different population entirely. Bounded gauges: mdca.cloud_discovery.apps{category} — the one label worth having, since "how many generativeAi apps is my traffic reaching" is the question this collector exists to answer — plus .apps.by_risk{risk_band} (our banding of Microsoft's 1-10 riskScore, not Microsoft's, because a band is what an operator alerts on), .streams{is_snapshot_report}, .log_files.total and .apps_truncated. Log twins mdca.discovered_app and mdca.discovery_stream carry the per-entity detail: app name, domains, user/IP counts, traffic bytes, and the stream each app was seen on. App names and domains are log-only — a series keyed by discovered-app name grows with the shadow-IT surface, which is precisely the unbounded shape #112 forbids. Live-measured 2026-07-28: 6 streams, 100 apps on the first page with a nextLink, 28 distinct categories, and one app carrying 355 domains — so domain truncation is the NORMAL case here, not an edge case, and the uncapped domain_count is what carries the truth | `graph` | `/beta/security/dataDiscovery/cloudAppDiscovery/uploadedStreams` then `.../{streamId}/aggregatedAppsDetails(period=duration'P30D')` per stream, paged on @odata.nextLink. 1+N where N is the stream count — bounded by tenant configuration, not tenant size, so no per-tenant cost gate. The aggregate endpoint throttles (observed 429 `Request was throttled. Expected available in 1 second.`); the shared client owns backoff | `CloudApp-Discovery.Read.All` | `beta` (beta-only, so Experimental and opt-in per #183 — and note this is a SECOND, unrelated transport to the mdca domain: mdca.discovery_parse reaches the legacy portal API with a static token, while this uses ordinary Graph app-only auth and `CloudApp-Discovery.Read.All` (granted 2026-07-28). Streams and apps degrade independently. `category` IS declared to internal/wirecheck, unusually for this project: 28 distinct values across 100 live rows is a genuine value set rather than the single observation that leaves most fields unwatched, and the Enum is derived from the map the collector keys on so the watched set cannot drift from the mapped set) | 1h | `graph2otel.api.unexpected{collector,field,kind}`, `mdca.cloud_discovery.apps{category}`, `mdca.cloud_discovery.apps.by_risk{risk_band}`, `mdca.cloud_discovery.apps_truncated`, `mdca.cloud_discovery.log_files.total`, `mdca.cloud_discovery.streams{is_snapshot_report}`, plus a log twin per `mdca.discovered_app`, `mdca.discovery_stream` |

## Defender for Cloud Apps — logs (window collectors)

| Collector | Collects | Intrinsic transport | Graph endpoint(s) | Required scope(s) | License / beta / volume | Interval | Lag | Log event |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `mdca.discovery_parse` | Cloud Discovery parse health, the signal no uploader can see: a Cloud Discovery upload 200s the moment the blob lands and a parse task is QUEUED, but the parse runs asynchronously and writes its verdict ONLY to the governance log — so 22 consecutive silent parse failures on the live tenant (2026-07-17) produced no signal anywhere while every upload reported green. Emits one log twin (`mdca.discovery_parse`) per DiscoveryParseLogTask (template, is_success, input_stream_id, transactions/cloud-services counts; Error severity on failure, a queued task is `state=pending` and NOT a failure), a `mdca.discovery.parse.last_success.age` gauge per stream that keeps CLIMBING when uploads stop (the alert-on-silence signal a failure counter cannot produce), and a `mdca.discovery.parse.tasks` counter by outcome. Dedupes on `_id`+`updateTimestamp` because a task's status MUTATES after creation — a naive `_id` dedupe ships only the queued state and hides every verdict. Experimental: the legacy portal API has no Graph successor | `mdca` | `{tenant}.{region}.portal.cloudappsecurity.com/api/v1/governance/` — the MDCA legacy portal API, NOT Graph: a static `Authorization: Token` credential (no azidentity, no app-role scope), 30 req/min per tenant, server-side filtering only on `timestamp` (taskName/status filters silently return empty, so they are applied client-side) | — | `beta` | 10m | 5m | `mdca.discovery_parse` |

## Self-observability — metrics (snapshot collectors)

| Collector | Collects | Intrinsic transport | Graph endpoint(s) | Required scope(s) | License / beta / volume | Interval | Metric namespace |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `graph2otel.blob_categories` | Blob-category census (#238): diffs the tenant's microsoft.aadiam diagnostic-settings categories against the Azure Storage containers graph2otel's blob collectors read. Bounded gauge graph2otel.blob.categories{state} where state ∈ consumed / enabled_unread (billed but read by no collector — Warn) / mapped_but_disabled (a collector polls a container that is never written — Error) / disabled; one log twin per category. Corrects #134 — the poller reads microsoft.aadiam as itself (Entra roles, not Azure RBAC); the microsoft.intune half needs a different identity and is out of scope | `graph` | `GET providers/microsoft.aadiam/diagnosticSettings` (ARM control plane, management.azure.com audience) | — | runs only when blob ingest is configured (nil ARM otherwise → no-op); access is the poller's Entra roles, not a Graph scope, so RequiredPermissions is empty like defender.quarantine. Capability semantics — 'mapped' means a blob collector for the container is shipped, not that this tenant enabled it | 1h | `graph2otel.blob.categories{state,tenant_id}`, plus a log twin per `graph2otel.blob_category` |

<!-- END GENERATED COLLECTOR REFERENCE -->

## Notes

### The four polled sign-in streams are four collectors, not one

They all poll `/auditLogs/signIns`, but `signInEventTypes` filters cannot be combined into a single
query, so each event type needs its own poller. Each owns a distinct `CheckpointKey`
(`/auditLogs/signIns#<eventType>`) — without that they would share one checkpoint namespace and
dedupe each other's events away. The `beta` gate on three of them is about the `signInEventTypes`
filter, which returns HTTP 400 on v1.0; only the unfiltered interactive stream is v1.0 and
default-on. See `CLAUDE.md` for the verified live behavior.

### Blob collectors read Azure Storage, not Graph

Blob collectors read Azure Monitor diagnostic-settings output from Azure Storage. Some expose
categories for which Graph has **no endpoint at all**; others are scalable or fallback sources for
signals also available over Graph, including sign-ins, directory audits, provisioning, and risk
detections. The Graph/blob alternatives are mutually exclusive per logical collector through the
`source` configuration — they are not a dual-ship mode.

The blob path declares no Graph scope. It needs the **`Storage Blob Data Reader`** data-plane role;
`Owner` is not enough because it grants container administration but not blob-content reads.

They **only register when `blob_ingest.account_url` is set** for the tenant. That one key is the
opt-in for the whole lane; a deployment that has not provisioned a storage account registers none of
them and is entirely untouched by this path.

Blob collectors emit per-record logs; collectors with a measured aggregation contract can also
derive bounded metrics from the same records. Their cursor is a byte offset per blob rather than a
watermark because Azure backfills records into already-closed hour buckets. Azure delivery is
at-least-once: measured duplicate rates are 2.7–4% by signal family and multiplicity reaches ×4.
The byte-offset consumer intentionally emits every stored copy, so counts and SIEM storage must
dedupe downstream on `id` or `request_id`. For why this path exists and how to provision it, see
[`blob-ingest.md`](.././blob-ingest.md).

### The export-report collectors need one write-level scope

`intune.app_install_status`, `intune.cert_inventory`, `intune.defender_agents`,
`intune.config_assignment_status`, `intune.noncompliant_settings`, and `intune.device_attestation`
poll the **Reports Export API**: `POST /deviceManagement/reports/exportJobs`, then poll job status and
download the result. Creating that job requires `DeviceManagementManagedDevices.ReadWrite.All` —
a write-level scope, and the single break in graph2otel's read-only property. graph2otel never
writes Intune configuration or device state through it. All six are opt-in (`beta`). See
[`permissions.md`](.././permissions.md#4-the-export-job-readwrite-caveat-gotcha-3) for the full
explanation.

### Cardinality

Every metric label above resolves to a bounded enum, a fixed threshold bucket, or an
admin-configured object name (policy/profile/ring/script name) — never a per-user, per-device, or
per-sign-in identifier. Per-entity detail is not withheld: it goes to the **logs**, which is what
the *Log event* column and the log twins in the *Metric namespace* column are. See
[`pii-cardinality-audit.md`](.././pii-cardinality-audit.md) and `SECURITY.md` for the full boundary
rule and the confirmed-clean audit result.
