O365 Management Activity API — how m365.activity actually works¶
The Office 365 Management Activity API (manage.office.com) feeds m365.activity (default-on) via internal/o365activityclient + internal/o365pipeline. It is a stable v1.0 transport (2,000 req/min per tenant) with a subscription → content-blob model built for continuous ingest — chosen over the Graph audit query API, which is beta-only on this tenant and 429s on rapid job creation (#100, #109). App roles: ActivityFeed.Read on the O365 Management APIs service principal (c5393580-f805-4401-95e8-94b7a6ef2fc2); add ActivityFeed.ReadDlp only when the operator explicitly selects DLP.All.
Everything below is live-measured (2026-07-16, #100) unless noted. The doc-vs-wire scorecard ran 6-0 against Microsoft's documentation — treat the docs as hypotheses.
Wire format traps¶
CreationTimeis NOT RFC3339. It arrives zone-less ("2015-06-29T20:03:19") —time.Parse(time.RFC3339, …)fails and would silently drop every record. The mapper triesRFC3339Nanofirst (so a futureZis honored), then falls back to"2006-01-02T15:04:05.999999999"parsed as UTC — with notime.Now()fallback (a stamped-on-arrival record silently claims to have happened now; worse than a drop).- Docs declare camelCase enums; the wire returns PascalCase. Requests take camelCase (
exchangeAdmin), responses return PascalCase (ExchangeAdmin) — the API passes the classic O365 names straight through (#98 corroborates from the query-API side). - The published
AuditLogRecordTypetable misses ~27% of this tenant's live types. Six observed members are absent from the docs at any int, plus RecordType 117. So the unknown-int path fires on day one:m365.activityemitsrecord_type_idunconditionally (the only lossless form) andrecord_typeonly when the int resolves. Never guess a name — a guessed name is a silent convergence break; an absent one is visibly unknown. Docs rows containing a space ("Viva Engage") are deliberately omitted — that is post-rename display text, the wire keeps the old name.
Subscription lifecycle traps¶
- AF20024 is undocumented and fires on every re-start of an enabled subscription.
POST /subscriptions/starton an already-enabled content type → 400AF20024("The subscription is already enabled. No property change."). It is a success condition (the desired state holds):IsAlreadyEnabledtreats it as one (55e3999). Without that, the collector works exactly once per tenant, then fails every tick forever. The test fake reproduces the 400 — a fake returning 200 unconditionally is kinder than the wire and proves nothing. POST /subscriptions/startis a WRITE operation — the second break in graph2otel's read-only property (after the Intune reports-export job).- No server-side filtering exists. Subscribe only to content types actually mapped — never
Audit.Generalby default (it is the catch-all, including Teams; opt-in only).
Windowing and retention¶
/subscriptions/contentcaps the list range at 24 HOURS —startTime/endTimemust both be set or both omitted and be ≤24h apart; a wider request → 400AF20055. Worse: a >24h range that does return 200 may be silently partial (per the API reference). Always chunk into ≤24h sub-windows (o365activityclient.MaxWindow).- There is NO 12-hour delay for first content (docs claim one). Content listed ~2 minutes after subscribe, with the oldest
contentCreated~22h before the subscription existed — Microsoft backfills on subscribe. Never build a "wait before first poll" behavior; never tell a reader an empty first poll is expected. - The "7-day content expiry" is unverified and probably a docs conflation. Live:
contentExpiration~20 days aftercontentCreated. AF20051 is a retrieval bound; AF20030/AF20055 are a startTime lookback bound (MaxLookback— measured). Always readcontentExpirationoff the wire; never derive expiry from a constant.
Cursor and dedupe rules (mutation-proven, o365pipeline)¶
- A cursor's clock and its eviction clock must be the SAME clock, and here it is the ARRIVAL clock. Seen-set ids evict on their blob's
contentCreated(arrival), never the record's ownCreationTime(event) — one content blob can contain events that occurred before an earlier blob's, so evicting on event time drops ids whose blobs are still re-listable inside the overlap window → silent duplicates. The rule is NOT "prefer arrival time" — it is that the two clocks must match; envelopetimeloses on the blob sign-in categories by the same rule (see blob-ingest.md). - Guard the empty dedupe id. A bare
"record:"key for an id-less record matches every later id-less record — record two onward vanish silently forever. Companion rule: undedupeable is degraded; misdated is wrong — only wrong justifies a drop. An id-less record is still emitted; a record with no parseable event time is dropped.
Operational behavior¶
- The defaults (
Audit.Exchange+Audit.SharePoint) carry ZERO content on a small tenant, and the collector looks 100% healthy doing it —runs=1 failures=0with an empty checkpoint is the steady state, not a fault.advance()moves the watermark only to blobs actually consumed. Anyone validating against defaults will conclude it is broken, or worse, that it works. m365.unified_auditmust be disabled whereverm365.activityruns — both emitm365.auditwith the same ids; both on = every record twice.
Current completion state¶
All four #100 residuals are discharged. The classic schema emits its UPN-shaped identity as user_id rather than asserting that every value is a UPN; the WindowCollector adapter lives in internal/o365pipeline; SECURITY.md records the subscription-start write; and DLP.All was live-measured with three records. A dedicated DLP.All mapper is deliberately absent: those records are normally empty on m7kni and DetectedValues contains the matched secret in cleartext. Any future mapper must be built from a live sample, emit classification metadata only, and exclude DetectedValues.