diff --git a/edge-apps/powerbi/src/services.lib.ts b/edge-apps/powerbi/src/services.lib.ts index de0de3b7d..6263b1806 100644 --- a/edge-apps/powerbi/src/services.lib.ts +++ b/edge-apps/powerbi/src/services.lib.ts @@ -3,6 +3,37 @@ import type { PowerBiError } from './services.types' export const DASHBOARD_READY_DELAY_MS = 1000 +// Power BI's "FailedToLoadModel" is transient (a stale/evicted dataset session); reload +// the report a few times to recover before giving up and showing the error screen. The +// delay gives the dataset time to come back and clears reload()'s 100ms throttle, so an +// immediate retry isn't silently dropped. +const MODEL_LOAD_ERROR = 'FailedToLoadModel' +export const MAX_MODEL_RELOADS = 3 +export const MODEL_RELOAD_DELAY_MS = 5000 + +export function isModelLoadError(error: PowerBiError): boolean { + return (error.message ?? '').includes(MODEL_LOAD_ERROR) +} + +// Build a real Error (so Sentry groups/titles it instead of "Object captured as exception"). +export function toReportableError(error: PowerBiError): Error { + return new Error( + error.message ?? error.detailedMessage ?? 'Power BI embed error', + ) +} + +// Flatten errorInfo to a string so Sentry's normalizeDepth doesn't truncate the nested +// array to "[Array]". +export function powerBiErrorContext( + error: PowerBiError, +): Record { + return { + source: 'powerbi-embed', + detailedMessage: error.detailedMessage, + errorInfo: JSON.stringify(error.technicalDetails?.errorInfo ?? null), + } +} + let embedService: service.Service | undefined export function getEmbedService(): service.Service { @@ -14,6 +45,8 @@ export function getEmbedService(): service.Service { return embedService } +const DEFAULT_ERROR_MESSAGE = 'Unable to load report' + export function showError(error: PowerBiError): void { const container = document.getElementById('embed-container') as HTMLElement container.innerHTML = '' @@ -24,9 +57,8 @@ export function showError(error: PowerBiError): void { const content = template.content.cloneNode(true) as DocumentFragment const messageEl = content.querySelector('.error-message') as HTMLElement - if (error.detailedMessage) { - messageEl.textContent = error.detailedMessage - } + messageEl.textContent = + error.detailedMessage ?? error.message ?? DEFAULT_ERROR_MESSAGE const table = content.querySelector('.error-details') as HTMLElement const rowTemplate = document.getElementById( diff --git a/edge-apps/powerbi/src/services.test.ts b/edge-apps/powerbi/src/services.test.ts index 22e2937a3..ecbd9ea18 100644 --- a/edge-apps/powerbi/src/services.test.ts +++ b/edge-apps/powerbi/src/services.test.ts @@ -46,7 +46,12 @@ function setupDom() { const embedCalls: Array<{ config: Record }> = [] const reportOn = mock(() => {}) const reportSetAccessToken = mock(async () => {}) -const fakeReport = { on: reportOn, setAccessToken: reportSetAccessToken } +const reportReload = mock(async () => {}) +const fakeReport = { + on: reportOn, + setAccessToken: reportSetAccessToken, + reload: reportReload, +} class FakeService { embed(_container: unknown, config: Record) { @@ -245,14 +250,45 @@ describe('services', () => { }) }) + // eslint-disable-next-line max-lines-per-function describe('initializePowerBI', () => { + let scheduled: Map void> + let nextTimerId: number + let originalSetTimeout: typeof setTimeout + let originalClearTimeout: typeof clearTimeout + beforeEach(() => { embedCalls.length = 0 reportOn.mockClear() reportSetAccessToken.mockClear() + reportReload.mockClear() + signalReady.mockClear() + scheduled = new Map() + nextTimerId = 1 + originalSetTimeout = globalThis.setTimeout + originalClearTimeout = globalThis.clearTimeout + globalThis.setTimeout = ((fn: () => void) => { + const id = nextTimerId++ + scheduled.set(id, fn) + return id + }) as unknown as typeof setTimeout + globalThis.clearTimeout = ((id: number) => { + scheduled.delete(id) + }) as unknown as typeof clearTimeout setupDom() }) + afterEach(() => { + globalThis.setTimeout = originalSetTimeout + globalThis.clearTimeout = originalClearTimeout + }) + + function runScheduledReloads() { + const pending = [...scheduled.values()] + scheduled.clear() + pending.forEach((fn) => fn()) + } + it('when embedding report, should embed with view permissions and return report', async () => { setScreenly({ embed_token: 'static-token', embed_url: REPORT_EMBED_URL }) @@ -264,8 +300,13 @@ describe('services', () => { tokenType: 'Embed', permissions: 'All', }) - expect(reportOn).toHaveBeenCalledWith('rendered', signalReady) expect(report).toBe(fakeReport) + + const renderedHandler = reportOn.mock.calls.find( + (call) => call[0] === 'rendered', + )?.[1] as () => void + renderedHandler() + expect(signalReady).toHaveBeenCalled() }) it('when token retrieval fails, should report, render error, and rethrow', async () => { @@ -284,23 +325,99 @@ describe('services', () => { ) }) - it('when embed fires error event, should report it to sentry and render error', async () => { + async function embedAndGetErrorHandler() { setScreenly({ embed_token: 'static-token', embed_url: REPORT_EMBED_URL }) await initializePowerBI() - const errorHandler = reportOn.mock.calls.find( + return reportOn.mock.calls.find( (call) => call[0] === 'error', )?.[1] as (event: { detail: unknown }) => void + } + + it('when embed fires non-model error, should report it and render error', async () => { + const errorHandler = await embedAndGetErrorHandler() errorHandler({ detail: { detailedMessage: 'TokenExpired' } }) - expect(reportError).toHaveBeenCalledWith( - { detailedMessage: 'TokenExpired' }, - { source: 'powerbi-embed' }, - ) + const [reportedError, context] = reportError.mock.calls[0] as [ + Error, + Record, + ] + expect(reportedError.message).toBe('TokenExpired') + expect(context.source).toBe('powerbi-embed') + expect(reportReload).not.toHaveBeenCalled() expect(document.querySelector('.error-message')?.textContent).toBe( 'TokenExpired', ) }) + + it('when embed fires model-load error, should reload instead of showing error', async () => { + const errorHandler = await embedAndGetErrorHandler() + + errorHandler({ detail: { message: 'X_FailedToLoadModel_Y' } }) + runScheduledReloads() + + expect(reportReload).toHaveBeenCalledTimes(1) + expect(document.querySelector('.error-message')).toBeNull() + }) + + it('when reload request fails and attempts remain, should retry instead of showing error', async () => { + const errorHandler = await embedAndGetErrorHandler() + reportReload.mockImplementationOnce(async () => { + throw new Error('reload failed') + }) + + errorHandler({ detail: { message: 'X_FailedToLoadModel_Y' } }) + runScheduledReloads() + await new Promise((resolve) => originalSetTimeout(resolve, 0)) + + expect(reportError).toHaveBeenCalledWith(expect.any(Error), { + source: 'powerbi-reload', + }) + expect(document.querySelector('.error-container')).toBeNull() + + runScheduledReloads() + expect(reportReload).toHaveBeenCalledTimes(2) + }) + + it('when duplicate errors fire before reload, should schedule only one reload', async () => { + const errorHandler = await embedAndGetErrorHandler() + const modelError = { detail: { message: 'X_FailedToLoadModel_Y' } } + + errorHandler(modelError) + errorHandler(modelError) + runScheduledReloads() + + expect(reportReload).toHaveBeenCalledTimes(1) + }) + + it('when report renders before reload fires, should cancel pending reload', async () => { + const errorHandler = await embedAndGetErrorHandler() + const renderedHandler = reportOn.mock.calls.find( + (call) => call[0] === 'rendered', + )?.[1] as () => void + + errorHandler({ detail: { message: 'X_FailedToLoadModel_Y' } }) + renderedHandler() + runScheduledReloads() + + expect(reportReload).not.toHaveBeenCalled() + }) + + it('when model-load errors exceed max reloads, should show error', async () => { + const errorHandler = await embedAndGetErrorHandler() + const modelError = { detail: { message: 'X_FailedToLoadModel_Y' } } + + errorHandler(modelError) + runScheduledReloads() + errorHandler(modelError) + runScheduledReloads() + errorHandler(modelError) + runScheduledReloads() + errorHandler(modelError) + + expect(reportReload).toHaveBeenCalledTimes(3) + expect(document.querySelector('.error-container')).not.toBeNull() + }) }) }) @@ -329,5 +446,13 @@ describe('services.lib', () => { expect(document.querySelector('.error-value')?.textContent).toBe('403') expect(signalReady).toHaveBeenCalled() }) + + it('when only message present, should render message', () => { + showError({ message: 'X_FailedToLoadModel_Y' }) + + expect(document.querySelector('.error-message')?.textContent).toBe( + 'X_FailedToLoadModel_Y', + ) + }) }) }) diff --git a/edge-apps/powerbi/src/services.ts b/edge-apps/powerbi/src/services.ts index b057d3492..82e3b32c1 100644 --- a/edge-apps/powerbi/src/services.ts +++ b/edge-apps/powerbi/src/services.ts @@ -7,8 +7,13 @@ import { } from './utils' import { DASHBOARD_READY_DELAY_MS, + MAX_MODEL_RELOADS, + MODEL_RELOAD_DELAY_MS, getEmbedService, + isModelLoadError, + powerBiErrorContext, showError, + toReportableError, } from './services.lib' import type { EmbedToken, PowerBiError } from './services.types' import { reportError } from '@screenly/edge-apps/utils' @@ -76,6 +81,45 @@ export function initTokenRefreshLoop( ) } +// Drives model-load recovery. Both a fresh error event and a failed reload funnel through +// reloadOrShowError so they share one retry budget. While a reload is pending, further +// errors are ignored so duplicates don't stack overlapping reloads. A successful render +// calls reset(), cancelling any reload still waiting on its delay. +function createReloadController(report: Embed) { + let attempts = 0 + let timer: ReturnType | undefined + + function reset() { + if (timer !== undefined) { + clearTimeout(timer) + timer = undefined + } + attempts = 0 + } + + function reloadOrShowError(detail: PowerBiError) { + if (timer !== undefined) { + return + } + + if (attempts >= MAX_MODEL_RELOADS) { + showError(detail) + return + } + + attempts += 1 + timer = setTimeout(() => { + timer = undefined + report.reload().catch((reloadError) => { + reportError(reloadError, { source: 'powerbi-reload' }) + reloadOrShowError(detail) + }) + }, MODEL_RELOAD_DELAY_MS) + } + + return { reset, reloadOrShowError } +} + export async function initializePowerBI(): Promise { const embedUrl = screenly.settings.embed_url const resourceType = getEmbedTypeFromUrl(embedUrl) @@ -95,33 +139,47 @@ export async function initializePowerBI(): Promise { throw error } - const report = getEmbedService().embed( - document.getElementById('embed-container') as HTMLElement, - { - embedUrl: embedUrl, - accessToken: initialToken.token, - type: resourceType, - tokenType: models.TokenType.Embed, - permissions: models.Permissions.All, - settings: { - filterPaneEnabled: false, - navContentPaneEnabled: false, - hideErrors: true, - }, + const container = document.getElementById('embed-container') as HTMLElement + const report = getEmbedService().embed(container, { + embedUrl: embedUrl, + accessToken: initialToken.token, + type: resourceType, + tokenType: models.TokenType.Embed, + permissions: models.Permissions.All, + settings: { + filterPaneEnabled: false, + navContentPaneEnabled: false, + hideErrors: true, }, - ) + }) + + // powerbi-client also dispatches a DOM 'error' CustomEvent that bubbles to window, where + // Sentry's global handler double-captures it; we report it explicitly below instead. + container.addEventListener('error', (event) => event.stopPropagation()) + + const reloadController = createReloadController(report) if (resourceType === 'report') { - report.on('rendered', screenly.signalReadyForRendering) + report.on('rendered', () => { + reloadController.reset() + screenly.signalReadyForRendering() + }) } else if (resourceType === 'dashboard') { report.on('loaded', () => { setTimeout(screenly.signalReadyForRendering, DASHBOARD_READY_DELAY_MS) }) } - report.on('error', function (event) { - reportError(event.detail, { source: 'powerbi-embed' }) - showError(event.detail as PowerBiError) + report.on('error', (event) => { + const detail = event.detail as PowerBiError + reportError(toReportableError(detail), powerBiErrorContext(detail)) + + if (isModelLoadError(detail)) { + reloadController.reloadOrShowError(detail) + return + } + + showError(detail) }) if (!screenly.settings.embed_token) { diff --git a/edge-apps/powerbi/src/services.types.ts b/edge-apps/powerbi/src/services.types.ts index 2791251a9..e1e01640c 100644 --- a/edge-apps/powerbi/src/services.types.ts +++ b/edge-apps/powerbi/src/services.types.ts @@ -4,6 +4,7 @@ export interface EmbedToken { } export interface PowerBiError { + message?: string detailedMessage?: string technicalDetails?: { errorInfo?: Array<{ key: string; value: string | number | undefined }>