Wrapping MSGraphClientV3 to count calls fails silently — and your fail-safe catch hides it
The obvious way to instrument Graph calls — reassigning client.api on the instance returned by msGraphClientFactory — does not take effect. In strict mode (which every TS module is) the assignment throws, and because instrumentation is written to be fail-safe, the catch swallows it. Nothing breaks, nothing is measured, nothing is logged. Build the wrapper with Object.create instead, and assert that the assignment stuck.
Nabízející se způsob, jak měřit volání Graphu – přepsat client.api na instanci, kterou vrátí msGraphClientFactory – se neuplatní. Ve strict módu (a v něm je každý TS modul) přiřazení vyhodí výjimku, a protože se instrumentace píše jako fail-safe, catch ji spolkne. Nic se nerozbije, nic se nezměří, nic se nezaloguje. Obal postavte přes Object.create a ověřte, že přiřazení opravdu platí.
Symptom
You add call counting / throttle tracking around Microsoft Graph in an SPFx web part. SharePoint REST calls are recorded fine. Graph calls record zero — not a wrong number, exactly zero — while the app works normally and the console is clean.
It reads as "this app doesn't call Graph", which is the one conclusion nobody double-checks.
Cause
Two things combine:
MSGraphClientV3instances handed out bycontext.msGraphClientFactory.getClient()do not accept a reassignment ofapi. Under strict mode that assignment raises aTypeError.- Instrumentation is (correctly) written to never break the call it wraps, so it sits inside
try { … } catch { /* no telemetry, but a working client */ }. That catch now hides a total failure rather than an edge case.
// ❌ looks right, does nothing
const orig = client.api.bind(client);
client.api = (path) => wrapRequest(orig(path), path); // throws, swallowed
Fix
Do not mutate the client — return a child object whose prototype is the client. Assignment on a fresh, extensible object always works, everything else is inherited, and api() delegates to the real client as the receiver so no internal state is touched through the wrong this.
const TERMINALS = ['get', 'post', 'patch', 'put', 'delete'];
function wrapRequest(req: { [k: string]: unknown }, path: string): unknown {
// fluent methods (.version(), .select(), .header()) return `this`,
// so overriding the terminals once on this instance is enough
TERMINALS.forEach(m => {
const fn = req[m];
if (typeof fn !== 'function') return;
const call = fn as (...a: unknown[]) => Promise<unknown>;
req[m] = function (...args: unknown[]): Promise<unknown> {
return call.apply(req, args).then(
(out) => { record(path, 200); return out; },
(e) => { record(path, statusOf(e), e); throw e; }
);
};
});
return req;
}
export function instrument(c: MSGraphClientV3): MSGraphClientV3 {
const real = c as unknown as { api?: (p: string) => unknown };
if (typeof real.api !== 'function') return c;
const origApi = real.api;
const proxy = Object.create(c) as { api?: (p: string) => unknown };
proxy.api = (path: string) => wrapRequest(origApi.call(c, path) as never, path);
// ⚠ the whole point: prove the assignment took
if (proxy.api === origApi) { console.warn('Graph instrumentation did not attach'); return c; }
return proxy as unknown as MSGraphClientV3;
}
Apply it at the single place the client is produced, not at each call site:
const factory = {
getClient: (v: string) => context.msGraphClientFactory.getClient(v as '3').then(instrument)
};
Wrapping per call site works too, but the gap returns with the first newly written client.api(...) — and nobody notices, because the symptom is a number that is merely low.
What you can and cannot measure this way
- ✅ call counts per endpoint, and 429/503 with
Retry-Afterwhen the client surfaces headers on the error - ❌ quota headroom (
RateLimit-*) on success — the client hands you the parsed body, never the response
Traps
- A fail-safe
catchmay swallow a failure, but it must not swallow the fact that the feature is off. Wherevercatchmeans "silent no-op", put an assertion next to it that the thing actually attached. Otherwise you ship a feature that pretends to measure. - Verify instrumentation against an independent signal, never against itself. "Telemetry says 0" and "the app makes no calls" look identical from the inside. The browser's network tab settled it here: 7 requests to
graph.microsoft.comduring one scan versus 0 recorded. - Do not add retries in the wrapper. Graph carries writes as well as reads; a helpfully retried
sendMailsends twice.
See also
- Unknown managed properties fail silently — the same shape of bug: a quiet zero that reads as a fact