A drill-down into the save-guarantee telemetry that detects when local notebook content fails to persist to the server. This page groups the raw error events into actionable buckets, separates real system bugs from expected user behavior, and points at the specific code path that causes each pattern.
The headline metric on the weekly
dashboard is save success rate, which counts attempted saves and
whether they returned a 2xx. But many content-loss incidents never reach the save endpoint at all
— the autosave callback never fires, or the user leaves a collab session with dirty buffers. The
save-guarantee telemetry catches those by running an independent watchdog that
compares local and saved content; when they diverge it spins up a save-monitor timer and logs
whatever state it observes.
The raw events emitted by that watchdog look alike on the surface (they all say "Save monitor — save is not succeeded during the monitoring"), so the question this page answers is: which of those events represent an actual bug we should fix?
| Cluster | Database | Table | Filter |
|---|---|---|---|
pbiclients.eastus |
appinsights |
customEvents |
TL_extensionName == 'DES' |
pbiclientseu.northeurope |
appinsights |
customEvents |
same as above, unioned for global coverage |
Component name: saveGuaranteeHandlers - saveMonitor. Emitted from
trident-de-ds-app on the frontend. No backend telemetry is needed for this analysis
— every signal we group on is observable in the browser.
Two timers run side by side on every collaboration-enabled notebook tab:
contentMonitorHandlers.ts) — long-period timer
that periodically compares the local notebook JSON against the latest saved blob. If they
match, the timer restarts. If they diverge, it kicks off…
saveMonitorHandlers.ts) — short-period watchdog
that starts after content monitor detects divergence. It captures a snapshot of
isDirty, isSaveLeader, and collabJoinStatus at start,
and again at timeout. While it runs, the React save handler is expected to fire
setSaveTriggerInfo → setSaveHandleInfo('sent') →
setSaveHandleInfo('save-response-received'); the AzNB onSave event
then clears the timer cleanly.
The three branches above are the only ways the timer can fire without a clean exit. The rest of this page focuses on branch A because in current PROD data it accounts for >99% of all save-monitor failures, and branch B is consistently zero.
Last 7 days, PROD only. Counts are events / unique aznb instances / unique artifacts.
| Code | Message | Events | Instances | Artifacts |
|---|---|---|---|---|
| C | Save monitor — save is not succeeded during the monitoring (umbrella event) | 301,348 | 17,359 | 11,327 |
| A | Save monitor — save is not triggred | 299,928 | 17,198 | 11,241 |
| B | Save monitor — save is triggered while it is not handled | 0 | 0 | 0 |
| D | Save monitor — trigger save explicitly (safety-net fallback) | 175,331 | 7,669 | — |
What this tells us:
Branch A fires whenever isSaveTriggered === false at timeout. That predicate is
insufficient to decide whether anything is actually wrong — a notebook can be non-dirty by the
time the timer expires, the user can be a non-leader in a collab session (where saves are
delegated), the collab session can have died, etc. So we slice the same dataset by three more
dimensions captured in errorInfo:
isDirtyWhenTimeout — is there still unsaved content right now?isSaveLeaderWhenTimeout — does this client own the save responsibility?collabJoinStatusWhenTimeout — is the collab session healthy?
(joined, inactive, failed, in-progress)| # | Scenario | Predicate (at timeout) | Events | % of A | Verdict |
|---|---|---|---|---|---|
| S1 | Dirty cleared by timeout | dirty=false |
235,711 | 78.6% | benign content was eventually saved or reloaded via another path |
| S2 | Leader + dirty + joined, autosave never fired | dirty=true, leader=true, collab=joined |
24,860 | 8.3% | SYSTEM BUG the autosave callback didn't invoke aznb.save() |
| S3a | User left collab with dirty buffer | dirty=true, collab=inactive |
25,488 | 8.5% | user scenario already covered by the "content might be lost" message bar |
| S3b | Collab session failed | dirty=true, collab=failed |
746 | 0.2% | watch the explicit-save fallback fired but didn't clear the monitor |
| S4 | Non-leader in collab | dirty=true, leader=false, collab=joined |
13,069 | 4.4% | expected only the save-leader writes in real-time collab |
| S5 | Other (in-progress, mixed) | — | ~54 | <0.1% | transient states during session setup |
The local isDirty flag was true when the monitor started, but is false by the time
the timer fires. In practice this happens when:
onSave event didn't reach the
save-monitor before the timer expired.No follow-up action is needed for S1 events — they are an artifact of the watchdog being more eager than the clean-exit signal. Filtering them out is the first step of any save-guarantee triage.
This is the only branch worth chasing. The predicate is unambiguous: this client owns the save responsibility, the collab session is healthy, the content is unsaved, and a full save-monitor window elapsed without the autosave callback firing. The relevant call sites are:
// trident-de-ds-app/apps/de-ds-extension/src/notebook/pages/Authoring/FileViews/Notebook/NotebookFileView.tsx:2133
onSaveTriggered: (...) => {
// ...
setSaveTriggerInfo(artifact.objectId); // ← this is what's supposed to flip isSaveTriggered=true
}
// trident-de-ds-app/apps/de-ds-extension/src/notebook/saveGuaranteeHandlers/saveMonitorHandlers.ts:93-122
const timerId = setTimeout(() => {
if (!existingItem?.isSaveTriggered) {
handleSaveNotTrigger(...); // ← we reach here for every S2 event
}
// ...
}, saveMonitorTimerIntervalInMs);
Since branch B is zero, the failure is upstream of the React handler: the AzNB runtime's
autosave scheduler — not the trident layer — failed to invoke onSaveTriggered during
the watch window. Hypotheses to test (instrument these in
@azure-notebooks/core-contract):
Affected scale: ~2,100 unique aznb instances and ~1,300 unique notebooks per week. Weekday baseline 3.7k–5.0k events/day, weekend dip to ~1.2k/day — pure traffic curve, no regression.
The collab session moved to inactive (the user explicitly disconnected, navigated
away, or the keepalive died) while the local notebook was still dirty. The save-monitor still
fires because the watchdog doesn't know to stand down — but the user has already been warned via
the SaveNotCompletedMessageBar, which is rendered as soon as the monitor flags
content might be lost.
This is the inherent risk of disconnecting with unsaved changes; the codebase already handles it. No additional action needed unless the goal is to reduce these (e.g., a confirmation dialog on tab close with dirty content, separate from save reliability).
When collabJoinStatusWhenTimeout === 'failed' and the client is not the leader,
handleSaveNotTrigger takes the fallback branch and calls aznb.save()
directly. The fact that we still see the "save not triggered" log for these means the fallback
completed without raising — but the dirty flag is still set 30 seconds later. Most likely causes:
CapacityNotActive in
useAutoSaveNotebook).
Volume is low (~750 events/week) but the fix is cheap: emit a follow-up log right after
aznbInstance.save() in handleSaveNotTrigger that records the outcome,
so the failures stop being indistinguishable from S2.
In a real-time collab session, only the save leader writes to backend; co-editors are intentional
free-riders on that save. isDirty is true on every client that holds local CRDT
changes (it tracks divergence from the last-saved version), so a non-leader will always
look dirty if anyone has typed since the last save. The save-monitor takes the
"content might be lost" branch, which is correct: until the leader saves, the
non-leader's view of "saved" is whatever the leader last wrote.
No bug here; the message bar messaging is the right outcome.
| Action | Target | Why |
|---|---|---|
| Instrument autosave scheduler heartbeats | @azure-notebooks/core-contract (AzNB runtime) |
S2 is the only real bug. Without runtime-side state (timer paused?, dirty event lost?), there's no way to discriminate root causes. |
Log explicit-save outcome in handleSaveNotTrigger |
saveMonitorHandlers.ts:177-189 |
S3b currently looks identical to S2 in raw telemetry. A two-line addition disambiguates. |
Filter S1 (dirtyOut=false) from the weekly dashboard's "save guarantee"
panel |
weekly dashboard queries.py |
78% of the noise is benign. Filtering S1 makes regressions in S2 stand out cleanly. |
| None for S3a and S4 | — | Already handled by SaveNotCompletedMessageBar / collab semantics. |
| 📂 Repo | dev.azure.com/msdata/A365/_git/trident-de-ds-app |
| Save monitor | apps/de-ds-extension/src/notebook/saveGuaranteeHandlers/saveMonitorHandlers.ts |
| Content monitor | apps/de-ds-extension/src/notebook/saveGuaranteeHandlers/contentMonitorHandlers.ts |
| Event names | apps/de-ds-extension/src/notebook/saveGuaranteeHandlers/stepNameConstants.ts |
| Autosave React handler | apps/de-ds-extension/src/notebook/pages/Authoring/FileViews/Notebook/NotebookFileView.hooks/useAutoSave.ts |
| Save-trigger call site | apps/de-ds-extension/src/notebook/pages/Authoring/FileViews/Notebook/NotebookFileView.tsx (search setSaveTriggerInfo) |
let _start = ago(7d);
let base = cluster('pbiclients.eastus').database('appinsights').customEvents
| union cluster('pbiclientseu.northeurope').database('appinsights').customEvents
| where timestamp >= _start
| where customDimensions.TL_extensionName == 'DES'
| where customDimensions.TL_componentName == 'saveGuaranteeHandlers - saveMonitor'
| where customDimensions.TL_environment == 'PROD'
| extend msg = tostring(customDimensions.TL_message);
base
| extend errorType = case(
msg == 'Save guarantee: Save monitor - save is not triggred', 'A_save_not_triggered',
msg == 'Save guarantee: Save monitor - save is triggered while it is not handled', 'B_save_not_handled',
msg == 'Save guarantee: Save monitor - save is not succeeded during the monitoring', 'C_save_not_succeeded',
msg == 'Save guarantee: Save monitor - trigger save explicitly', 'D_explicit_save_fallback',
'other')
| where errorType != 'other'
| extend artifactId = iff(tostring(customDimensions.TU_artifactId) != '',
tostring(customDimensions.TU_artifactId),
substring(tostring(customDimensions.TU_documentId), 49))
| extend aznbInstanceId = tostring(customDimensions.TU_aznbInstanceId)
| summarize events = count(),
uniqueInstances = dcount(aznbInstanceId),
uniqueArtifacts = dcount(artifactId)
by errorType
| order by errorType asc
let _start = ago(7d);
cluster('pbiclients.eastus').database('appinsights').customEvents
| union cluster('pbiclientseu.northeurope').database('appinsights').customEvents
| where timestamp >= _start
| where customDimensions.TL_extensionName == 'DES'
| where customDimensions.TL_componentName == 'saveGuaranteeHandlers - saveMonitor'
| where customDimensions.TL_environment == 'PROD'
| where customDimensions.TL_message == 'Save guarantee: Save monitor - save is not triggred'
| extend errorInfo = parse_json(tostring(customDimensions.TL_error))
| extend dirtyOut = tostring(coalesce(errorInfo.isDirtyWhenTimeout, customDimensions.TU_isDirtyWhenTimeout)),
leaderOut = tostring(coalesce(errorInfo.isSaveLeaderWhenTimeout, customDimensions.TU_isSaveLeaderWhenTimeout)),
collabOut = tostring(coalesce(errorInfo.collabJoinStatusWhenTimeout, customDimensions.TU_collabJoinStatusWhenTimeout))
| extend artifactId = iff(tostring(customDimensions.TU_artifactId) != '',
tostring(customDimensions.TU_artifactId),
substring(tostring(customDimensions.TU_documentId), 49))
| extend aznbInstanceId = iff(tostring(errorInfo.aznbInstanceId) != '',
tostring(errorInfo.aznbInstanceId),
tostring(customDimensions.TU_aznbInstanceId))
| extend scenario = case(
dirtyOut == 'false', 'S1_benign_dirty_cleared',
leaderOut == 'true' and collabOut == 'joined' and dirtyOut == 'true', 'S2_bug_leader_save_callback_missed',
collabOut == 'inactive' and dirtyOut == 'true', 'S3a_user_left_collab',
collabOut == 'failed' and dirtyOut == 'true', 'S3b_collab_failed',
collabOut == 'joined' and leaderOut == 'false' and dirtyOut == 'true', 'S4_collab_member_not_leader',
'S5_other')
| summarize events = count(),
uniqueInstances = dcount(aznbInstanceId),
uniqueArtifacts = dcount(artifactId)
by scenario
| order by scenario asc
let _start = ago(7d);
cluster('pbiclients.eastus').database('appinsights').customEvents
| union cluster('pbiclientseu.northeurope').database('appinsights').customEvents
| where timestamp >= _start
| where customDimensions.TL_extensionName == 'DES'
| where customDimensions.TL_componentName == 'saveGuaranteeHandlers - saveMonitor'
| where customDimensions.TL_environment == 'PROD'
| where customDimensions.TL_message == 'Save guarantee: Save monitor - save is not triggred'
| extend errorInfo = parse_json(tostring(customDimensions.TL_error))
| extend dirtyOut = tostring(coalesce(errorInfo.isDirtyWhenTimeout, customDimensions.TU_isDirtyWhenTimeout)),
leaderOut = tostring(coalesce(errorInfo.isSaveLeaderWhenTimeout, customDimensions.TU_isSaveLeaderWhenTimeout)),
collabOut = tostring(coalesce(errorInfo.collabJoinStatusWhenTimeout, customDimensions.TU_collabJoinStatusWhenTimeout))
| where dirtyOut == 'true' and leaderOut == 'true' and collabOut == 'joined'
| extend aznbInstanceId = tostring(customDimensions.TU_aznbInstanceId)
| summarize buggyInstances = dcount(aznbInstanceId) by bin(timestamp, 1d)
| render timechart
let _start = ago(7d);
cluster('pbiclients.eastus').database('appinsights').customEvents
| union cluster('pbiclientseu.northeurope').database('appinsights').customEvents
| where timestamp >= _start
| where customDimensions.TL_extensionName == 'DES'
| where customDimensions.TL_componentName == 'saveGuaranteeHandlers - saveMonitor'
| where customDimensions.TL_environment == 'PROD'
| where customDimensions.TL_message == 'Save guarantee: Save monitor - save is not triggred'
| extend errorInfo = parse_json(tostring(customDimensions.TL_error))
| extend dirtyOut = tostring(coalesce(errorInfo.isDirtyWhenTimeout, customDimensions.TU_isDirtyWhenTimeout)),
leaderOut = tostring(coalesce(errorInfo.isSaveLeaderWhenTimeout, customDimensions.TU_isSaveLeaderWhenTimeout)),
collabOut = tostring(coalesce(errorInfo.collabJoinStatusWhenTimeout, customDimensions.TU_collabJoinStatusWhenTimeout))
| where dirtyOut == 'true' and leaderOut == 'true' and collabOut == 'joined'
| extend artifactId = iff(tostring(customDimensions.TU_artifactId) != '',
tostring(customDimensions.TU_artifactId),
substring(tostring(customDimensions.TU_documentId), 49))
| extend aznbInstanceId = tostring(customDimensions.TU_aznbInstanceId)
| summarize hitCount = count(), uniqueInstances = dcount(aznbInstanceId) by artifactId
| top 20 by hitCount
| order by hitCount desc