Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions edge-apps/powerbi/src/services.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment thread
rusko124 marked this conversation as resolved.

// 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<string, unknown> {
return {
source: 'powerbi-embed',
detailedMessage: error.detailedMessage,
errorInfo: JSON.stringify(error.technicalDetails?.errorInfo ?? null),
}
Comment thread
rusko124 marked this conversation as resolved.
}

let embedService: service.Service | undefined

export function getEmbedService(): service.Service {
Expand All @@ -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 = ''
Expand All @@ -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(
Expand Down
141 changes: 133 additions & 8 deletions edge-apps/powerbi/src/services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ function setupDom() {
const embedCalls: Array<{ config: Record<string, unknown> }> = []
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<string, unknown>) {
Expand Down Expand Up @@ -245,14 +250,45 @@ describe('services', () => {
})
})

// eslint-disable-next-line max-lines-per-function
describe('initializePowerBI', () => {
let scheduled: Map<number, () => 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 })

Expand All @@ -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 () => {
Expand All @@ -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<string, unknown>,
]
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()
})
})
})

Expand Down Expand Up @@ -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',
)
})
})
})
94 changes: 76 additions & 18 deletions edge-apps/powerbi/src/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<typeof setTimeout> | 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<Embed> {
const embedUrl = screenly.settings.embed_url
const resourceType = getEmbedTypeFromUrl(embedUrl)
Expand All @@ -95,33 +139,47 @@ export async function initializePowerBI(): Promise<Embed> {
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)
Comment thread
rusko124 marked this conversation as resolved.
})

if (!screenly.settings.embed_token) {
Expand Down
1 change: 1 addition & 0 deletions edge-apps/powerbi/src/services.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface EmbedToken {
}

export interface PowerBiError {
message?: string
detailedMessage?: string
technicalDetails?: {
errorInfo?: Array<{ key: string; value: string | number | undefined }>
Expand Down