Skip to content

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; for the collectors: config block that enables/disables and retunes them, see 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). 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), 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 / volumeneeds-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.

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.

StateReason
disabledtransport_not_configured
disabledexperimental_not_enabled
disabledhigh_volume_not_enabled
disableddisabled_by_config
blockedpermission_denied
blockedlicense_unavailable
coveredcovered_by_alternative
startingno_completed_run
startingtransport_fallback
healthy_emptytransport_fallback
healthy_emptyempty
healthytransport_fallback
healthysuccess
limitedpartial_license
degradedpermission_denied
degradedsource_error
degradeddecode_error
degradedmapping_error
degradedmissing_event_time
degradedaccounting_mismatch
degradedtimeout
degradedpanic
failedsource_error
faileddecode_error
failedmapping_error
failedmissing_event_time
failedaccounting_mismatch
failedtimeout
failedpanic
startup_failedlicense_detection_failed
startup_failedtransport_initialization_failed
startup_failedinvalid_transport_configuration
startup_failedcredential_initialization_failed
startup_failedgraph_client_initialization_failed

Entra ID — metrics (snapshot collectors)

CollectorCollectsIntrinsic transportGraph endpoint(s)Required scope(s)License / beta / volumeIntervalMetric namespace
entra.access_reviewsEntra 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 documentationgraph/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 parentAccessReview.Read.AllNo 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-skip1hentra.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_governanceAgent 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 ownersgraph/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.Allruns 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 set15mentra.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.agreementsTerms of Use agreements + acceptance stategraph/agreements, /agreements/{id}/acceptancesAgreement.Read.All, AgreementAcceptance.Read.Allneeds-license/entra_p115mentra.agreements.acceptances.total{agreement,state}, entra.agreements.total
entra.app_ownershipApplication/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 FICgraph/applications?$expand=owners, /servicePrincipals?$expand=owners (v1.0), /beta/applications?$expand=federatedIdentityCredentialsApplication.Read.Allruns 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 requirement6hentra.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_policyTenant-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 overlookedgraph/policies/authenticationMethodsPolicyPolicy.Read.AuthenticationMethod15mentra.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_strengthAuthentication-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 threegraph/policies/authenticationStrengthPolicies (v1.0)Policy.Read.Allruns 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 set15mentra.auth_strength.policies{policy_type,requirements_satisfied}, plus a log twin per entra.auth_strength_policy
entra.conditional_accessConditional 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 droppedgraph/identity/conditionalAccess/policies, /identity/conditionalAccess/namedLocationsPolicy.Read.Allneeds-license/entra_p115mentra.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.consentOAuth2 permission grants + app-role assignment consent surfacegraph/oauth2PermissionGrants, app role assignmentsDirectory.Read.All, Application.Read.All15mentra.consent.grants.total{consent_type,privilege}, plus a log twin per entra.consent_grant
entra.credential_expiryApp + service principal credential (secret/certificate) expiry bucketsgraph/applications, /servicePrincipals ($select=keyCredentials,passwordCredentials)Application.Read.All15mentra.credentials.expiring.total{credential_type,expiry_bucket,owner_type}, plus a log twin per entra.app_credential
entra.cross_tenant_accessEntra 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 wiregraph/policies/crossTenantAccessPolicy, /policies/crossTenantAccessPolicy/default, /policies/crossTenantAccessPolicy/partners (v1.0)Policy.Read.Allruns 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 set15mentra.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_itemsRecycle-bin census: recoverable soft-deleted directory objects by type + near-purge state, log twin per objectgraph/directory/deletedItems/microsoft.graph.{user,group,application,servicePrincipal,device}Directory.Read.All30mentra.deleted_items.count{near_purge,object_type}, plus a log twin per entra.deleted_item
entra.devicesDirectory device inventory: trust type, compliance, managed state, OS, stalenessgraph/devices, /devices/$countDevice.Read.All15mentra.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_countsTenant-wide directory object counts by typegraph/{type}/$count per object typeDirectory.Read.All5mentra.directory.objects.total{type}
entra.directory_recoveryEntra 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 restoregraph/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.Allruns 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 timestamp1hentra.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.domainsDomain verification/authentication posturegraph/domainsDomain.Read.All15mentra.domains.federated.total, entra.domains.total{authentication_type,is_verified}, plus a log twin per entra.domain
entra.groupsGroup population by type/membership/security/mail-enabled, role-assignable countgraph/groups/$count (filtered)Group.Read.All5mentra.groups.role_assignable.total, entra.groups.total{group_type,mail_enabled,membership_type,security_enabled}
entra.gsaGlobal 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 rowgraph/beta/networkAccess/tenantStatus, /beta/networkAccess/forwardingProfiles, /beta/networkAccess/filteringPolicies, /beta/networkAccess/settings/conditionalAccess, /beta/networkAccess/settings/crossTenantAccess, /beta/networkAccess/connectivity/remoteNetworksPolicy.Read.Allbeta (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)6hentra.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.licensingSKU consumption + prepaid/enabled unitsgraph/subscribedSkusLicenseAssignment.Read.All, Group.Read.All15mentra.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_registrationMFA/SSPR/passwordless registration + capability status, per-method counts, admin MFA-capable splitgraph/reports/authenticationMethods/userRegistrationDetailsAuditLog.Read.Allneeds-license/entra_p11hentra.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.organizationTenant posture: on-prem sync state/age, tenant age, verified domain count, tenant typegraph/organizationOrganization.Read.All15mentra.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_alertsMicrosoft'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 emittedgraph/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.Directorybeta (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)6hentra.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_policiesPIM 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 approvalgraph/policies/roleManagementPolicies?$expand=rules, /policies/roleManagementPolicyAssignments (directory scope)Policy.Read.All, RoleManagement.Read.DirectoryEntra 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)6hentra.pim.role_policy.requirement{caller,enabled,requirement}, plus a log twin per entra.pim_role_policy
entra.privileged_groupsMember 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-opsgraph/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 membersGroup.Read.AllOpt-in per tenant via tenants[].privileged_groups.group_ids; no new scope beyond the Group.Read.All the tenant already grants5mentra.privileged_groups.accessible{group_id}, entra.privileged_groups.members.total{group_id}, plus a log twin per entra.privileged_group
entra.recommendationsEntra recommendations catalog (status, priority)graph/directory/recommendations (beta)DirectoryRecommendations.Read.Allbeta30mentra.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_tenantsEntra 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-enablegraph/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.Allbeta (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))6hentra.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.riskCurrent 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_deletedgraph/identityProtection/riskyUsers, /identityProtection/riskyServicePrincipals, /directory/deletedItems/microsoft.graph.userIdentityRiskyUser.Read.All, IdentityRiskyServicePrincipal.Read.All, User.Read.Allpartial-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)15mentra.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_agentsCurrent 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/riskyAgentsIdentityRiskyAgent.Read.Allbeta15mentra.risky_agents.total{risk_level,risk_state}, graph2otel.api.unexpected{collector,field,kind}, plus a log twin per entra.risky_agent
entra.role_definitionsDirectory 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 Administratorgraph/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.Directoryruns 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 set1hentra.role_definitions.definitions{is_built_in,is_enabled}, plus a log twin per entra.role_definition
entra.rolesStanding directory-role membership; PIM active/eligible/permanent assignment countsgraph/directoryRoles, /roleManagement/directory/roleAssignmentScheduleInstances, .../roleEligibilityScheduleInstancesRoleManagement.Read.Directory, RoleAssignmentSchedule.Read.Directory, RoleEligibilitySchedule.Read.Directorypartial-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)10mentra.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_scoreLatest 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 pollgraph/security/secureScores, /security/secureScoreControlProfilesSecurityEvents.Read.All1hentra.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_activityMeasured 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 conflatedgraph/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 dropReports.Read.Allbeta (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)30mentra.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_activityStale service principals / app credentials (no recent sign-in), app sign-in result summarygraph/reports/servicePrincipalSignInActivities, /reports/appCredentialSignInActivities (beta)AuditLog.Read.All, Reports.Read.Allneeds-license/entra_p1, beta1hentra.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.syncerrorsHybrid 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 valuegraph/organization (sync-state probe), /users (full page-walk, client-side filtered)User.Read.All, Organization.Read.Allruns 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 sweep6hentra.directory.sync.errors.total{category,object_type,property_causing_error}, plus a log twin per entra.directory_sync_error
entra.tenant_policyTenant 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/featureRolloutPoliciesPolicy.Read.Allruns 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)6hentra.tenant_policy.scoped_policies{kind}, entra.tenant_policy.setting{setting}, plus a log twin per entra.tenant_policy
entra.usersUser population by account-enabled/user-type/on-prem-sync (marginal + joint user_type×account_enabled), stalenessgraph/users, /users/$count (GET /users?…&$count=true for signInActivity-based slices)User.Read.All, AuditLog.Read.Allpartial-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)15mentra.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)

CollectorCollectsIntrinsic transportGraph endpoint(s)Required scope(s)License / beta / volumeIntervalLagLog event
entra.agent_risk_detectionsIdentity 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/agentRiskDetectionsIdentityRiskyAgent.Read.Allbeta30m15mentra.agent_risk_detection
entra.directory_auditsDirectory 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/directoryAuditsAuditLog.Read.All5m15mentra.directory_audit
entra.network_access_trafficGlobal 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 namesgraph/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 pageNetworkAccess.Read.Allbeta, 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)5m15mentra.network_access_traffic
entra.provisioningProvisioning (sync) events (source: graph|blob — poll /auditLogs/provisioning, or consume the ProvisioningLogs diagnostic-settings container; exactly one per config)graph/auditLogs/provisioningAuditLog.Read.All15m15mentra.provisioning
entra.risk_detectionsIdentity 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/riskDetectionsIdentityRiskEvent.Read.Allneeds-license/entra_p230m15mentra.risk_detection
entra.security_alertsSecurity alerts (alerts_v2)graph/security/alerts_v2SecurityAlert.Read.All10m15mentra.security_alert
entra.security_incidentsSecurity incidents — the correlation layer above alerts_v2, grouping related alerts into one investigation ($top capped at 50, not 1000)graph/security/incidentsSecurityIncident.Read.All12m15mentra.security_incident
entra.signins.interactiveInteractive 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 betagraph/auditLogs/signIns (v1.0, unfiltered)AuditLog.Read.Allneeds-license/entra_p15m15mentra.signin
entra.signins.non_interactiveNon-interactive sign-in eventsgraph/auditLogs/signIns (beta, signInEventTypes filter)AuditLog.Read.Allneeds-license/entra_p1, beta5m15mentra.signin
entra.signins.service_principalService principal sign-in eventsgraph/auditLogs/signIns (beta, signInEventTypes filter)AuditLog.Read.Allneeds-license/entra_p1, beta5m15mentra.signin
entra.signins.managed_identityManaged identity sign-in eventsgraph/auditLogs/signIns (beta, signInEventTypes filter)AuditLog.Read.Allneeds-license/entra_p1, beta5m15mentra.signin
entra.service_principal_risk_detectionsIdentity 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/servicePrincipalRiskDetectionsIdentityRiskyServicePrincipal.Read.All30m15mentra.service_principal_risk_detection

Entra ID — logs (blob collectors)

CollectorCollectsIntrinsic transportContainer (diagnostic category)Cursor keyRequired roleLicense / beta / volumeIntervalLog event
entra.directory_auditsDirectory 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)blobinsights-logs-auditlogs (AuditLogs)insights-logs-auditlogsStorage Blob Data Reader15mentra.directory_audit
entra.graph_activityOne 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 pathblobinsights-logs-microsoftgraphactivitylogs (MicrosoftGraphActivityLogs)insights-logs-microsoftgraphactivitylogsStorage Blob Data Reader5mentra.graph_activity
entra.graph_notificationsOne 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 outputblobinsights-logs-graphnotificationsactivitylogs (GraphNotificationsActivityLogs)insights-logs-graphnotificationsactivitylogsStorage Blob Data Reader15mentra.graph_notifications
entra.provisioningProvisioning (sync) events (source: graph|blob — poll /auditLogs/provisioning, or consume the ProvisioningLogs diagnostic-settings container; exactly one per config)blobinsights-logs-provisioninglogs (ProvisioningLogs)insights-logs-provisioninglogsStorage Blob Data Reader15mentra.provisioning
entra.risky_usersBlob 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 streamblobinsights-logs-riskyusers (RiskyUsers)insights-logs-riskyusersStorage Blob Data Reader15mentra.risky_service_principal, entra.risky_user
entra.risk_detectionsIdentity 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)blobinsights-logs-userriskevents (UserRiskEvents)insights-logs-userriskeventsStorage Blob Data Reader15mentra.risk_detection
entra.signins.microsoft_service_principalSign-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 fromblobinsights-logs-microsoftserviceprincipalsigninlogs (MicrosoftServicePrincipalSignInLogs)insights-logs-microsoftserviceprincipalsigninlogsStorage Blob Data Readerneeds-license/entra_p15mentra.signin
entra.signins.service_principal.blobService 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 startupblobinsights-logs-serviceprincipalsigninlogs (ServicePrincipalSignInLogs)insights-logs-serviceprincipalsigninlogsStorage Blob Data Readerneeds-license/entra_p15mentra.signin
entra.signins.non_interactive.blobNon-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 startupblobinsights-logs-noninteractiveusersigninlogs (NonInteractiveUserSignInLogs)insights-logs-noninteractiveusersigninlogsStorage Blob Data Readerneeds-license/entra_p15mentra.signin
entra.signins.managed_identity.blobManaged-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)blobinsights-logs-managedidentitysigninlogs (ManagedIdentitySignInLogs)insights-logs-managedidentitysigninlogsStorage Blob Data Readerneeds-license/entra_p15mentra.signin

Intune — metrics (snapshot collectors)

CollectorCollectsIntrinsic transportGraph endpoint(s)Required scope(s)License / beta / volumeIntervalMetric namespace
intune.app_install_statusPer-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 formreport_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hgraph2otel.api.unexpected{collector,field,kind}, intune.app_install_status.installations{install_state,platform}, plus a log twin per intune.app_install_status
intune.apple_tokensAPNS/VPP token expiry + synced device counts; DEP onboarding settings polled best-effortgraph/deviceManagement/applePushNotificationCertificate, /deviceAppManagement/vppTokens, /deviceManagement/depOnboardingSettings (beta, isolated)DeviceManagementServiceConfig.Read.All, DeviceManagementApps.Read.AllAPNS/VPP are v1.0 and default-on; the DEP sub-fetch is beta but isolated, so it does not gate the collector6hintune.apple_token.days_until_expiry{state,token_name,type}, intune.apple_token.synced_device_count{token_name}
intune.app_protectionApp protection (MAM) policy inventory + assignment state; flagged registrations; WIP policy countgraph/deviceAppManagement/iosManagedAppProtections, androidManagedAppProtections, targetedManagedAppConfigurations, windowsInformationProtectionPolicies, mdmWindowsInformationProtectionPoliciesDeviceManagementApps.Read.All30mintune.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.autopilotAutopilot 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 detectablegraph/deviceManagement/windowsAutopilotDeviceIdentities, deployment profiles, /beta/deviceManagement/windowsAutopilotSettings (sync singleton)DeviceManagementServiceConfig.Read.Allbeta30mgraph2otel.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_deploymentPer-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 decodedreport_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.autopilot_deployment.deployments{deployment_status}, plus a log twin per intune.autopilot_deployment
intune.autopilot_deployment_appsPer-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 outcomereport_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.autopilot_deployment_apps.installs{policy_install_status}, plus a log twin per intune.autopilot_deployment_apps
intune.autopilot_deployment_scriptsPer-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 decodedreport_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.autopilot_deployment_scripts.executions{policy_install_status}, plus a log twin per intune.autopilot_deployment_scripts
intune.device_boot_securityPer-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 pathreport_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.device_boot_security.devices{os,posture,status}, plus a log twin per intune.device_boot_security
intune.certificatesCertificate state + days-until-expirygraph/deviceManagement/deviceConfigurations (per-profile managedDeviceCertificateStates), /deviceManagement/userPfxCertificatesDeviceManagementConfiguration.Read.Allbeta30mgraph2otel.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_inventoryDevice certificate inventory (thumbprints, serials, subject/issuer), via the Reports Export APIreport_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hgraph2otel.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_pkiIntune 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" meansgraphGET /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.Allbeta (read-only DeviceManagementCloudCA.Read.All; beta-only surface (v1.0 has no such segment), so Experimental — opt-in via explicit enable)12hintune.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.complianceTenant-wide + per-policy compliance state rollupsgraph/deviceManagement/deviceCompliancePolicies, device compliance statesDeviceManagementConfiguration.Read.All15mintune.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_statusPer-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 assignmentreport_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.config_assignment_status.assignments{policy_platform,report_status}, plus a log twin per intune.config_assignment_status
intune.config_profile_device_statusPer-(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_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.config_profile_device_status.devices{report_status}, plus a log twin per intune.config_profile_device_status
intune.config_profilesConfiguration profile status + version, per-setting stategraph/deviceManagement/deviceConfigurations (fan-out per profile)DeviceManagementConfiguration.Read.All30mgraph2otel.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_statusPer-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_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.config_setting_status.devices{setting_device_status}, plus a log twin per intune.config_setting_status
intune.connectorsExchange/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 silentlygraph/deviceManagement/exchangeConnectors, /deviceManagement/mobileThreatDefenseConnectors, NDES (beta, isolated), /beta/deviceManagement/androidManagedStoreAccountEnterpriseSettings (beta, isolated)DeviceManagementServiceConfig.Read.AllExchange/MTD are default-on; the NDES and Managed-Google-Play sub-fetches are beta and isolated, so their failure does not gate the collector15mintune.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_agentsDefender agent health, via the Reports Export APIreport_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.defender_agents.count{signal}, intune.defender_agents.product_status{status}, plus a log twin per intune.defender_agent
intune.detected_appsDetected-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 seriesgraph/deviceManagement/detectedAppsDeviceManagementManagedDevices.Read.All1hintune.detected_apps.catalog_size, intune.detected_apps.device_count{app_name,platform}
intune.device_attestationPer-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 pathreport_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.device_attestation.devices{attestation_status,os}, plus a log twin per intune.device_attestation
intune.device_encryptionPer-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 unboundedgraphGET /deviceManagement/managedDeviceEncryptionStates (beta — v1.0 has no such segment)DeviceManagementManagedDevices.Read.Allbeta (read-only DeviceManagementManagedDevices.Read.All; beta endpoint, so opt-in via explicit enable)1hgraph2otel.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_policyManaged 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 statereport_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.devices_without_compliance_policy.devices{os}, plus a log twin per intune.devices_without_compliance_policy
intune.driver_update_summaryWindows 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 twinreport_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.driver_update_summary.devices{policy_id,policy_name,update_deployment_state}
intune.endpoint_analyticsUXA 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 forgraph/deviceManagement/userExperienceAnalytics* (v1.0 + beta)DeviceManagementManagedDevices.Read.Allbeta1hintune.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.enrollmentEnrollment configuration inventory (restrictions, VPP, ESP, etc.) + priority + versiongraph/deviceManagement/deviceEnrollmentConfigurationsDeviceManagementServiceConfig.Read.All15mgraph2otel.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_deniedEndpoint 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 denialsreport_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.epm_denied.denials{elevation_type}, plus a log twin per intune.epm_denied
intune.epm_elevation_eventsPer-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 twinreport_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.epm_elevation_events.count{elevation_type,result}, plus a log twin per intune.epm_elevation_events
intune.epm_elevationsEndpoint 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 reportreport_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.epm_elevations.count{elevation_type}, plus a log twin per intune.epm_elevations
intune.epm_elevations_by_publisherEndpoint 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_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.epm_elevations_by_publisher.count{elevation_type}, plus a log twin per intune.epm_elevations_by_publisher
intune.epm_elevations_by_userEndpoint 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_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.epm_elevations_by_user.count{elevation_governance}, plus a log twin per intune.epm_elevations_by_user
intune.feature_update_devicesWHICH 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_exportPOST /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 succeededDeviceManagementConfiguration.Read.All, DeviceManagementManagedDevices.ReadWrite.Allbeta (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)6hintune.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_summaryPer-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 twinreport_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.feature_update_summary.devices{feature_update_version,policy_id,policy_name,update_deployment_state}
intune.firewall_statusPer-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_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.firewall_status.devices{firewall_status}, plus a log twin per intune.firewall_status
intune.gpo_analyticsGPO migration readiness/analytics reportsgraph/deviceManagement/groupPolicyMigrationReports, /deviceManagement/groupPolicyConfigurationsDeviceManagementConfiguration.Read.Allbeta24hgraph2otel.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_inventoryPer-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 numbergraphGET /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.Allbeta (read-only DeviceManagementManagedDevices.Read.All; beta-only property, so opt-in via explicit enable)24hgraph2otel.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.malwareTenant malware/Defender overview (detected devices, by severity/category), per-device Defender protection/product stategraph/deviceManagement/windowsMalwareOverview, /deviceManagement/managedDevices/{id}/windowsProtectionStateDeviceManagementManagedDevices.Read.All30mgraph2otel.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.devicesManaged-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 itgraph/deviceManagement/managedDevices, managedDeviceOverviewDeviceManagementManagedDevices.Read.All1hgraph2otel.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_appsMobile app catalog (type, publishing state); mobile app config policy statusgraph/deviceAppManagement/mobileApps, app configsDeviceManagementApps.Read.All30mgraph2otel.api.unexpected{collector,field,kind}, intune.mobile_app_config.status{policy_name,status}, intune.mobile_apps.count{app_type,publishing_state}
intune.noncompliant_settingsPer-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 answerreport_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hgraph2otel.api.unexpected{collector,field,kind}, intune.noncompliant_settings.count{os,setting_status}, plus a log twin per intune.noncompliant_setting
intune.quality_update_summaryPer-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 twinreport_exportPOST /deviceManagement/reports/exportJobsDeviceManagementManagedDevices.ReadWrite.Allbeta (the ReadWrite scope creates the export JOB and nothing else; graph2otel never writes Intune configuration or device state)6hintune.quality_update_summary.devices{expedite_release_date,policy_id,policy_name,update_deployment_state}
intune.rbacIntune'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 backendgraphGET /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 sizeDeviceManagementRBAC.Read.Allread-only DeviceManagementRBAC.Read.All; v1.0 (and beta) both serve these collections, so default-on and NOT Experimental6hintune.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_statesPer-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-outgraphGET /deviceManagement/deviceHealthScripts/{id}/deviceRunStates (beta)DeviceManagementConfiguration.Read.All, DeviceManagementManagedDevices.Read.Allbeta (read-only DeviceManagementConfiguration.Read.All + DeviceManagementManagedDevices.Read.All; beta endpoint, so opt-in via explicit enable)1hgraph2otel.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.scriptsScript/remediation inventory, run summaries, and remediation overviewgraph/deviceManagement/deviceManagementScripts (Windows), deviceShellScripts (macOS), deviceHealthScripts (+ getRemediationSummary)DeviceManagementScripts.Read.Allbeta30mintune.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_catalogSettings Catalog policy inventory, template-based intents + per-intent device state, security baseline device stategraph/deviceManagement/configurationPolicies (beta), /deviceManagement/intents (+ deviceStateSummary), /deviceManagement/templates/{id}/deviceStateSummaryDeviceManagementConfiguration.Read.Allbeta30mintune.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.updatesWindows Update rings + feature/quality/driver update profile state, pause/rollbackgraph/deviceManagement/deviceConfigurations (ring subtype only, v1.0), /deviceManagement/windowsFeatureUpdateProfiles, windowsQualityUpdateProfiles/Policies, windowsDriverUpdateProfiles (beta)DeviceManagementConfiguration.Read.Allbeta (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)30mintune.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_updatesThe 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 intographGET /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.Allbeta (read-only WindowsUpdates.Read.All; beta-only surface, so opt-in via explicit enable)1hintune.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)

CollectorCollectsIntrinsic transportGraph endpoint(s)Required scope(s)License / beta / volumeIntervalLagLog event
intune.audit_eventsIntune 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 graph2otelgraph/deviceManagement/auditEventsDeviceManagementApps.Read.All15m15mintune.audit_event
intune.autopilot_eventsAutopilot deployment/enrollment events. Also rejects a server-side time $filter, so the window is bounded client-sidegraph/deviceManagement/autopilotEvents (beta, no v1.0 equivalent)DeviceManagementManagedDevices.Read.Allbeta20mintune.autopilot_event
intune.enrollment_eventsEnrollment troubleshooting events. The endpoint rejects a server-side $filter on its time field, so the window is bounded client-side insteadgraph/deviceManagement/troubleshootingEventsDeviceManagementManagedDevices.Read.Allneeds-license/intune20m15mintune.enrollment_event

Intune — logs (blob collectors)

CollectorCollectsIntrinsic transportContainer (diagnostic category)Cursor keyRequired roleLicense / beta / volumeIntervalLog event
intune.cloud_pc_auditOne 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 configuredblobinsights-logs-windows365auditlogs (Windows365AuditLogs)insights-logs-windows365auditlogsStorage Blob Data Reader15mintune.cloud_pc_audit
intune.compliance_alertsOne 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 complianceblobinsights-logs-operationallogs (OperationalLogs)insights-logs-operationallogsStorage Blob Data Reader15mintune.compliance_alert
intune.devices_blobBlob 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 skippedblobinsights-logs-devices (Devices)insights-logs-devicesStorage Blob Data Reader15mintune.managed_device

M365 — logs (window collectors)

CollectorCollectsIntrinsic transportGraph endpoint(s)Required scope(s)License / beta / volumeIntervalLagLog event
m365.message_tracePer-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 meaningexchange_onlinePOST 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 enabledhigh-volume opt-in10m15mm365.message_trace
m365.unified_auditThe 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 startupaudit_queryPOST /security/auditLog/queries (beta — the documented v1.0 form 404s on a live tenant even under a token carrying the scope)AuditLogsQuery.Read.Allbeta30m1hm365.audit
m365.activityThe 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 startupo365_activitymanage.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.Read10m5mm365.audit

M365 — metrics (snapshot collectors)

CollectorCollectsIntrinsic transportGraph endpoint(s)Required scope(s)License / beta / volumeIntervalMetric namespace
m365.collaboration_activityDaily 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 onegraph/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.Allruns 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 set6hm365.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_usageExchange 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 nonegraph/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 erroringReports.Read.All, ReportSettings.Read.All6hm365.mailbox_usage.quota_status.mailboxes{quota_state}, m365.mailbox_usage.storage_used_bytes, plus a log twin per m365.mailbox_usage
m365.servicehealthM365 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 derivedgraph/admin/serviceAnnouncement/healthOverviews?$expand=issuesServiceHealth.Read.All15mm365.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.servicemessagesM365 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 Warngraph/admin/serviceAnnouncement/messagesServiceMessage.Read.All1hm365.service_messages.total{category,severity}, plus a log twin per m365.service_message
m365.sharepoint_settingsTenant 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 Warngraph/admin/sharepoint/settingsSharePointTenantSettings.Read.All1hgraph2otel.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.storageSharePoint + 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 ongraph/reports/getSharePointSiteUsageStorage, /reports/getOneDriveUsageStorage, /reports/getSharePointSiteUsageDetail, /reports/getOneDriveUsageAccountDetailReports.Read.All6hm365.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.teamsMicrosoft 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 grantedgraph/teams, /teams/{id}?$select=summary,isArchived, /beta/teams/{id}/installedApps, /beta/teams/{id}/channelsTeam.ReadBasic.All, TeamSettings.Read.All, TeamsAppInstallation.Read.All, Channel.ReadBasic.All1hm365.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_domainsWhich 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 dataexchange_onlinePOST 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 set1hm365.exchange.accepted_domains{domain_type,is_default_domain}, plus a log twin per m365.exchange_accepted_domain
m365.exchange_audit_bypassWhich 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 onexchange_onlinePOST 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 set1hm365.exchange.audit_bypass.enabled_count, m365.exchange.audit_bypass.examined, plus a log twin per m365.exchange_audit_bypass
m365.exchange_audit_configExchange 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 itexchange_onlinePOST 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 set1hm365.exchange.audit_config.enabled{setting}, plus a log twin per m365.exchange_audit_config
m365.exchange_connection_filterThe 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 setexchange_onlinePOST 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 set1hm365.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_connectorsExchange 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 presentexchange_onlinePOST 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 set1hm365.exchange.connectors{direction,enabled}, m365.exchange.connectors.without_tls{direction}, plus a log twin per m365.exchange_connector
m365.exchange_dkimExchange 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 Infoexchange_onlinePOST 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 set1hm365.exchange.dkim.signing{enabled,status}, plus a log twin per m365.exchange_dkim_config
m365.exchange_mailboxesExchange 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 sentexchange_onlinePOST 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 set1hm365.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_configExchange 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 madeexchange_onlinePOST 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 set1hm365.exchange.org_config.setting_enabled{setting}, plus a log twin per m365.exchange_org_config
m365.exchange_outbound_spamOutbound 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 oneexchange_onlinePOST 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 set1hm365.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_domainsExchange 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 falseexchange_onlinePOST 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 set1hm365.exchange.remote_domains{auto_forward_enabled}, plus a log twin per m365.exchange_remote_domain
m365.exchange_transport_rulesExchange 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 verbatimexchange_onlinePOST 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 set1hm365.exchange.transport_rules{rule_mode,state}, m365.exchange.transport_rules.redirecting{state}, plus a log twin per m365.exchange_transport_rule
m365.inbox_rulesCurrent-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 seenexchange_onlinePOST 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 gauges6hm365.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_delegationStanding 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 setexchange_onlinePOST 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 gauges6hm365.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)

CollectorCollectsIntrinsic transportGraph endpoint(s)Required scope(s)License / beta / volumeIntervalMetric namespace
purview.dlp_policiesDLP 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 testgraph/beta/security/dataSecurityAndGovernance/policyFilesInformationProtectionPolicy.Read.Allbeta (beta/Experimental (opt-in); readable on scopes the poller already holds, no data-plane registration. A 403 is a graceful skip)1hpurview.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_caseseDiscovery (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 timestampgraph/security/cases/ediscoveryCaseseDiscovery.Read.Allbeta1hpurview.ediscovery.cases{status}, plus a log twin per purview.ediscovery_case
purview.ediscovery_case_healtheDiscovery 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, /operationseDiscovery.Read.Allbeta (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)1hgraph2otel.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_labelsRetention 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 failinggraph/security/labels/retentionLabels, /security/triggerTypes/retentionEventTypesRecordsManagement.Read.Allneeds-license/purview_records_management1hgraph2otel.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_labelsSensitivity 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 nullgraph/security/dataSecurityAndGovernance/sensitivityLabelsSensitivityLabel.Readneeds-license/purview_information_protection1hgraph2otel.api.unexpected{collector,field,kind}, purview.labels.count{applicable_to}, plus a log twin per purview.sensitivity_label

Defender — logs (blob collectors)

CollectorCollectsIntrinsic transportContainer (diagnostic category)Cursor keyRequired roleLicense / beta / volumeIntervalLog event
defender.alert_evidenceOne 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 configuredblobinsights-logs-advancedhunting-alertevidence (AdvancedHunting-AlertEvidence)insights-logs-advancedhunting-alertevidenceStorage Blob Data Reader15mdefender.alert_evidence
defender.alert_infoOne 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 configuredblobinsights-logs-advancedhunting-alertinfo (AdvancedHunting-AlertInfo)insights-logs-advancedhunting-alertinfoStorage Blob Data Reader15mdefender.alert_info
defender.behavior_entityOne 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)blobinsights-logs-advancedhunting-behaviorentities (AdvancedHunting-BehaviorEntities)insights-logs-advancedhunting-behaviorentitiesStorage Blob Data Reader15mdefender.behavior_entity
defender.behaviorOne 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)blobinsights-logs-advancedhunting-behaviorinfo (AdvancedHunting-BehaviorInfo)insights-logs-advancedhunting-behaviorinfoStorage Blob Data Reader15mdefender.behavior
defender.cloud_app_eventOne 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 configuredblobinsights-logs-advancedhunting-cloudappevents (AdvancedHunting-CloudAppEvents)insights-logs-advancedhunting-cloudappeventsStorage Blob Data Reader15mdefender.cloud_app_event
defender.device_eventOne 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 configuredblobinsights-logs-advancedhunting-deviceevents (AdvancedHunting-DeviceEvents)insights-logs-advancedhunting-deviceeventsStorage Blob Data Reader15mdefender.device_event
defender.device_fileOne 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 configuredblobinsights-logs-advancedhunting-devicefileevents (AdvancedHunting-DeviceFileEvents)insights-logs-advancedhunting-devicefileeventsStorage Blob Data Reader15mdefender.device_file
defender.device_file_certificateOne 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 configuredblobinsights-logs-advancedhunting-devicefilecertificateinfo (AdvancedHunting-DeviceFileCertificateInfo)insights-logs-advancedhunting-devicefilecertificateinfoStorage Blob Data Reader15mdefender.device_file_certificate
defender.device_image_loadOne 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 configuredblobinsights-logs-advancedhunting-deviceimageloadevents (AdvancedHunting-DeviceImageLoadEvents)insights-logs-advancedhunting-deviceimageloadeventsStorage Blob Data Reader15mdefender.device_image_load
defender.device_infoOne 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 configuredblobinsights-logs-advancedhunting-deviceinfo (AdvancedHunting-DeviceInfo)insights-logs-advancedhunting-deviceinfoStorage Blob Data Reader15mdefender.device_info
defender.device_logonOne 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 configuredblobinsights-logs-advancedhunting-devicelogonevents (AdvancedHunting-DeviceLogonEvents)insights-logs-advancedhunting-devicelogoneventsStorage Blob Data Reader15mdefender.device_logon
defender.device_networkOne 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 configuredblobinsights-logs-advancedhunting-devicenetworkevents (AdvancedHunting-DeviceNetworkEvents)insights-logs-advancedhunting-devicenetworkeventsStorage Blob Data Reader15mdefender.device_network
defender.device_network_infoOne 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 configuredblobinsights-logs-advancedhunting-devicenetworkinfo (AdvancedHunting-DeviceNetworkInfo)insights-logs-advancedhunting-devicenetworkinfoStorage Blob Data Reader15mdefender.device_network_info
defender.device_processOne 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 configuredblobinsights-logs-advancedhunting-deviceprocessevents (AdvancedHunting-DeviceProcessEvents)insights-logs-advancedhunting-deviceprocesseventsStorage Blob Data Reader15mdefender.device_process
defender.device_registryOne 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 configuredblobinsights-logs-advancedhunting-deviceregistryevents (AdvancedHunting-DeviceRegistryEvents)insights-logs-advancedhunting-deviceregistryeventsStorage Blob Data Reader15mdefender.device_registry
defender.emailOne 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 configuredblobinsights-logs-advancedhunting-emailevents (AdvancedHunting-EmailEvents)insights-logs-advancedhunting-emaileventsStorage Blob Data Reader15mdefender.email
defender.email_attachmentOne 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 configuredblobinsights-logs-advancedhunting-emailattachmentinfo (AdvancedHunting-EmailAttachmentInfo)insights-logs-advancedhunting-emailattachmentinfoStorage Blob Data Reader15mdefender.email_attachment
defender.email_post_deliveryOne 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 configuredblobinsights-logs-advancedhunting-emailpostdeliveryevents (AdvancedHunting-EmailPostDeliveryEvents)insights-logs-advancedhunting-emailpostdeliveryeventsStorage Blob Data Reader15mdefender.email_post_delivery
defender.email_urlOne 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 configuredblobinsights-logs-advancedhunting-emailurlinfo (AdvancedHunting-EmailUrlInfo)insights-logs-advancedhunting-emailurlinfoStorage Blob Data Reader15mdefender.email_url
defender.identity_infoOne 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 configuredblobinsights-logs-advancedhunting-identityinfo (AdvancedHunting-IdentityInfo)insights-logs-advancedhunting-identityinfoStorage Blob Data Reader15mdefender.identity_info
defender.identity_logonOne 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 configuredblobinsights-logs-advancedhunting-identitylogonevents (AdvancedHunting-IdentityLogonEvents)insights-logs-advancedhunting-identitylogoneventsStorage Blob Data Reader15mdefender.identity_logon
defender.teams_messageOne 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)blobinsights-logs-advancedhunting-messageevents (AdvancedHunting-MessageEvents)insights-logs-advancedhunting-messageeventsStorage Blob Data Reader15mdefender.teams_message
defender.teams_message_urlOne 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)blobinsights-logs-advancedhunting-messageurlinfo (AdvancedHunting-MessageUrlInfo)insights-logs-advancedhunting-messageurlinfoStorage Blob Data Reader15mdefender.teams_message_url
defender.url_click_eventOne 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 configuredblobinsights-logs-advancedhunting-urlclickevents (AdvancedHunting-UrlClickEvents)insights-logs-advancedhunting-urlclickeventsStorage Blob Data Reader15mdefender.url_click_event

Defender — metrics (snapshot collectors)

CollectorCollectsIntrinsic transportGraph endpoint(s)Required scope(s)License / beta / volumeIntervalMetric namespace
defender.allow_block_listThe 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 itexchange_onlinePOST 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 set1hdefender.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_policiesMicrosoft 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 Infoexchange_onlinePOST 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 set1hdefender.mdo.policies{policy_type}, defender.mdo.protection_enabled{policy_type,protection}, plus a log twin per defender.mdo_policy
defender.mdo_rulesThe 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 setsexchange_onlinePOST 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 set1hdefender.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.quarantineHow 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 setexchange_onlinePOST 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 instead15mdefender.quarantine.held_messages.total{direction,entity_type,quarantine_type}, graph2otel.api.unexpected{collector,field,kind}, plus a log twin per defender.quarantine
defender.exposure_graphThe 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 censusgraphPOST /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 setThreatHunting.Read.All6hdefender.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_appDefender 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 setgraphPOST /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.AllThreatHunting.Read.All6hdefender.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_configDevice 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 setgraphPOST /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.AllThreatHunting.Read.All6hdefender.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_inventoryDevice 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 setgraphPOST /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.AllThreatHunting.Read.All6hdefender.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.vulnerabilitiesDevice 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 setgraphPOST /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.AllThreatHunting.Read.All6hdefender.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)

CollectorCollectsIntrinsic transportGraph endpoint(s)Required scope(s)License / beta / volumeIntervalMetric namespace
mdca.cloud_discoveryThe 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 truthgraph/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 backoffCloudApp-Discovery.Read.Allbeta (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)1hgraph2otel.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)

CollectorCollectsIntrinsic transportGraph endpoint(s)Required scope(s)License / beta / volumeIntervalLagLog event
mdca.discovery_parseCloud 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 successormdca{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)beta10m5mmdca.discovery_parse

Self-observability — metrics (snapshot collectors)

CollectorCollectsIntrinsic transportGraph endpoint(s)Required scope(s)License / beta / volumeIntervalMetric namespace
graph2otel.blob_categoriesBlob-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 scopegraphGET 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 it1hgraph2otel.blob.categories{state,tenant_id}, plus a log twin per graph2otel.blob_category

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.

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 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 and SECURITY.md for the full boundary rule and the confirmed-clean audit result.