Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
36 changes: 34 additions & 2 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 @@ -24,8 +55,9 @@ 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
const message = error.detailedMessage ?? error.message
if (message) {
messageEl.textContent = message
}
Comment thread
rusko124 marked this conversation as resolved.
Outdated

const table = content.querySelector('.error-details') as HTMLElement
Expand Down
106 changes: 98 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,36 @@ describe('services', () => {
})
})

// eslint-disable-next-line max-lines-per-function
describe('initializePowerBI', () => {
let scheduled: Array<() => void>
let originalSetTimeout: typeof setTimeout

beforeEach(() => {
embedCalls.length = 0
reportOn.mockClear()
reportSetAccessToken.mockClear()
reportReload.mockClear()
signalReady.mockClear()
scheduled = []
originalSetTimeout = globalThis.setTimeout
globalThis.setTimeout = ((fn: () => void) => {
scheduled.push(fn)
return 0
}) as unknown as typeof setTimeout
setupDom()
})

afterEach(() => {
globalThis.setTimeout = originalSetTimeout
})

function runScheduledReloads() {
const pending = scheduled.slice()
scheduled.length = 0
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 +291,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 +316,73 @@ 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 model-load errors exceed max reloads, should show error', async () => {
const errorHandler = await embedAndGetErrorHandler()
const modelError = { detail: { message: 'X_FailedToLoadModel_Y' } }

errorHandler(modelError)
errorHandler(modelError)
errorHandler(modelError)
errorHandler(modelError)
runScheduledReloads()

expect(reportReload).toHaveBeenCalledTimes(3)
expect(document.querySelector('.error-container')).not.toBeNull()
})
})
})

Expand Down Expand Up @@ -329,5 +411,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',
)
})
})
})
72 changes: 54 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 @@ -95,33 +100,64 @@ 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())

let modelReloadAttempts = 0

// Both failure paths funnel here so they share one budget: a failed reload re-enters
// and counts as an attempt, just like a fresh model-load error event does.
function reloadOrShowError(detail: PowerBiError) {
if (modelReloadAttempts >= MAX_MODEL_RELOADS) {
showError(detail)
return
}
Comment thread
rusko124 marked this conversation as resolved.
Outdated

modelReloadAttempts += 1
setTimeout(() => {
report.reload().catch((reloadError) => {
reportError(reloadError, { source: 'powerbi-reload' })
reloadOrShowError(detail)
})
}, MODEL_RELOAD_DELAY_MS)
}

if (resourceType === 'report') {
report.on('rendered', screenly.signalReadyForRendering)
report.on('rendered', () => {
modelReloadAttempts = 0
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)) {
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