Skip to content

Commit fdc4553

Browse files
authored
Merge pull request #402 from fractal-analytics-platform/highlight-error-message
Highlighted relevant part of error message in workflow job log modal
2 parents ac3697f + 123f676 commit fdc4553

File tree

14 files changed

+589
-69
lines changed

14 files changed

+589
-69
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
*Note: Numbers like (\#123) point to closed Pull Requests on the fractal-web repository.*
22

3+
# Unreleased
4+
5+
* Improved sorting of users in dropdown of the admin jobs page (\#402).
6+
* Fixed bug in retrieval of job log from the admin jobs page (\#402).
7+
* Highlighted relevant part of the error message in workflow job log modal (\#402).
8+
* Made the error message directly accessible from the new workflow page (\#402).
9+
310
# 0.9.0
411

512
This release requires fractal-server 1.4.3.

__tests__/admin_jobs_page.test.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render } from '@testing-library/svelte';
3+
import { readable } from 'svelte/store';
4+
5+
// Mocking the page store
6+
vi.mock('$app/stores', () => {
7+
return {
8+
page: readable({
9+
data: {
10+
users: [
11+
{ id: 1, email: '[email protected]' },
12+
{ id: 2, email: '[email protected]' },
13+
{ id: 3, email: '[email protected]' },
14+
{ id: 4, email: '[email protected]' }
15+
],
16+
userInfo: {
17+
id: 2
18+
}
19+
}
20+
})
21+
};
22+
});
23+
24+
// Mocking public variables
25+
vi.mock('$env/dynamic/public', () => {
26+
return { env: {} };
27+
});
28+
29+
// The component to be tested must be imported after the mock setup
30+
import page from '../src/routes/admin/jobs/+page.svelte';
31+
32+
describe('Admin jobs page', () => {
33+
it('Users in dropdown are correctly sorted', async () => {
34+
const result = render(page);
35+
const dropdown = result.getByLabelText('User');
36+
const options = dropdown.querySelectorAll('option');
37+
expect(options.length).eq(5);
38+
expect(options[0].text).eq('All');
39+
expect(options[1].text).eq('[email protected]');
40+
expect(options[2].text).eq('[email protected]');
41+
expect(options[3].text).eq('[email protected]');
42+
expect(options[4].text).eq('[email protected]');
43+
});
44+
});

__tests__/job_utilities.test.js

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { it, expect } from 'vitest';
2+
import { extractJobErrorParts, extractRelevantJobError } from '$lib/common/job_utilities.js';
3+
4+
const completeTracebackError = `TASK ERROR:Task id: 15 (Create OME-Zarr structure), e.workflow_task_order=0
5+
TRACEBACK:
6+
2024-01-29 16:52:02,328; INFO; START create_ome_zarr task
7+
Traceback (most recent call last):
8+
File "/tmp/FRACTAL_TASKS_DIR/.fractal/fractal-tasks-core0.14.1/venv/lib/python3.10/site-packages/fractal_tasks_core/tasks/create_ome_zarr.py", line 470, in <module>
9+
run_fractal_task(
10+
File "/tmp/FRACTAL_TASKS_DIR/.fractal/fractal-tasks-core0.14.1/venv/lib/python3.10/site-packages/fractal_tasks_core/tasks/_utils.py", line 79, in run_fractal_task
11+
metadata_update = task_function(**pars)
12+
File "pydantic/decorator.py", line 40, in pydantic.decorator.validate_arguments.validate.wrapper_function
13+
File "pydantic/main.py", line 341, in pydantic.main.BaseModel.__init__
14+
pydantic.error_wrappers.ValidationError: 1 validation error for CreateOmeZarr
15+
allowed_channels
16+
field required (type=value_error.missing)
17+
`;
18+
19+
const shortTracebackError = `TASK ERROR:Task id: 20 (Create OME-Zarr structure), e.workflow_task_order=0
20+
TRACEBACK:
21+
Command "/tmp/FRACTAL_TASKS_DIR/.fractal/fractal-tasks-core0.14.1/venv/bin/python" is not valid. Hint: make sure that it is executable.`;
22+
23+
it('detect parts of a workflow task error message with complete traceback', () => {
24+
const parts = extractJobErrorParts(completeTracebackError);
25+
expect(parts.length).eq(3);
26+
expect(parts[0].text).eq(
27+
'TASK ERROR:Task id: 15 (Create OME-Zarr structure), e.workflow_task_order=0'
28+
);
29+
expect(parts[0].highlight).eq(true);
30+
expect(parts[1].text).eq(`TRACEBACK:
31+
2024-01-29 16:52:02,328; INFO; START create_ome_zarr task
32+
Traceback (most recent call last):
33+
File "/tmp/FRACTAL_TASKS_DIR/.fractal/fractal-tasks-core0.14.1/venv/lib/python3.10/site-packages/fractal_tasks_core/tasks/create_ome_zarr.py", line 470, in <module>
34+
run_fractal_task(
35+
File "/tmp/FRACTAL_TASKS_DIR/.fractal/fractal-tasks-core0.14.1/venv/lib/python3.10/site-packages/fractal_tasks_core/tasks/_utils.py", line 79, in run_fractal_task
36+
metadata_update = task_function(**pars)
37+
File "pydantic/decorator.py", line 40, in pydantic.decorator.validate_arguments.validate.wrapper_function
38+
File "pydantic/main.py", line 341, in pydantic.main.BaseModel.__init__`);
39+
expect(parts[1].highlight).eq(false);
40+
expect(parts[2].text)
41+
.eq(`pydantic.error_wrappers.ValidationError: 1 validation error for CreateOmeZarr
42+
allowed_channels
43+
field required (type=value_error.missing)`);
44+
expect(parts[2].highlight).eq(true);
45+
});
46+
47+
it('detect parts of a workflow task error message with short traceback', () => {
48+
const parts = extractJobErrorParts(shortTracebackError);
49+
expect(parts.length).eq(3);
50+
expect(parts[0].text).eq(
51+
'TASK ERROR:Task id: 20 (Create OME-Zarr structure), e.workflow_task_order=0'
52+
);
53+
expect(parts[0].highlight).eq(true);
54+
expect(parts[1].text).eq('TRACEBACK:');
55+
expect(parts[1].highlight).eq(false);
56+
expect(parts[2].text).eq(
57+
`Command "/tmp/FRACTAL_TASKS_DIR/.fractal/fractal-tasks-core0.14.1/venv/bin/python" is not valid. Hint: make sure that it is executable.`
58+
);
59+
expect(parts[2].highlight).eq(true);
60+
});
61+
62+
it('detect parts of a workflow task error message without traceback', () => {
63+
const parts = extractJobErrorParts('foo');
64+
expect(parts.length).eq(1);
65+
expect(parts[0].text).eq('foo');
66+
expect(parts[0].highlight).eq(false);
67+
});
68+
69+
it('extract relevant part of a workflow task error message with complete traceback', () => {
70+
const relevantError = extractRelevantJobError(completeTracebackError);
71+
expect(relevantError)
72+
.eq(`TASK ERROR:Task id: 15 (Create OME-Zarr structure), e.workflow_task_order=0
73+
pydantic.error_wrappers.ValidationError: 1 validation error for CreateOmeZarr
74+
allowed_channels
75+
field required (type=value_error.missing)`);
76+
});
77+
78+
it('extract relevant part of a workflow task error message with short traceback', () => {
79+
const relevantError = extractRelevantJobError(shortTracebackError);
80+
expect(relevantError).eq(
81+
`TASK ERROR:Task id: 20 (Create OME-Zarr structure), e.workflow_task_order=0
82+
Command "/tmp/FRACTAL_TASKS_DIR/.fractal/fractal-tasks-core0.14.1/venv/bin/python" is not valid. Hint: make sure that it is executable.`
83+
);
84+
});
85+
86+
it('extract relevant part of a workflow task error message without traceback', () => {
87+
const relevantError = extractRelevantJobError('foo');
88+
expect(relevantError).eq('foo');
89+
});
90+
91+
it('extract relevant part of a workflow task error with max lines reached', () => {
92+
const relevantError = extractRelevantJobError(completeTracebackError, 3);
93+
expect(relevantError)
94+
.eq(`TASK ERROR:Task id: 15 (Create OME-Zarr structure), e.workflow_task_order=0
95+
pydantic.error_wrappers.ValidationError: 1 validation error for CreateOmeZarr
96+
allowed_channels
97+
[...]`);
98+
});
99+
100+
it('extract relevant part of a workflow task error with max lines not reached', () => {
101+
const relevantError = extractRelevantJobError(completeTracebackError, 4);
102+
expect(relevantError)
103+
.eq(`TASK ERROR:Task id: 15 (Create OME-Zarr structure), e.workflow_task_order=0
104+
pydantic.error_wrappers.ValidationError: 1 validation error for CreateOmeZarr
105+
allowed_channels
106+
field required (type=value_error.missing)`);
107+
});
108+
109+
it('detect parts of a workflow JobExecutionError', () => {
110+
const error = `JOB ERROR:
111+
TRACEBACK:
112+
JobExecutionError
113+
114+
Job cancelled due to executor shutdown.`;
115+
const parts = extractJobErrorParts(error);
116+
expect(parts.length).eq(3);
117+
expect(parts[0].text).eq('JOB ERROR:');
118+
expect(parts[0].highlight).eq(true);
119+
expect(parts[1].text).eq('TRACEBACK:');
120+
expect(parts[1].highlight).eq(false);
121+
expect(parts[2].text).eq(`JobExecutionError
122+
123+
Job cancelled due to executor shutdown.`);
124+
expect(parts[2].highlight).eq(true);
125+
});
126+
127+
it('detect parts of an error with multiple tracebacks', () => {
128+
const log = `TASK ERROR:Task id: 1400 (Measure Features), e.workflow_task_order=10
129+
TRACEBACK:
130+
Traceback (most recent call last):
131+
File "/redacted/anaconda3/envs/fractal-server-1.4.0/lib/python3.10/site-packages/fractal_server/app/runner/_common.py", line 408, in call_single_parallel_task
132+
raise e
133+
File "/redacted/anaconda3/envs/fractal-server-1.4.0/lib/python3.10/site-packages/fractal_server/app/runner/_common.py", line 401, in call_single_parallel_task
134+
_call_command_wrapper(
135+
File "/redacted/anaconda3/envs/fractal-server-1.4.0/lib/python3.10/site-packages/fractal_server/app/runner/_common.py", line 184, in _call_command_wrapper
136+
raise TaskExecutionError(err)
137+
fractal_server.app.runner.common.TaskExecutionError: 2024-02-05 15:56:19,240; INFO; START measure_features task
138+
Traceback (most recent call last):
139+
File "/redacted/fractal-demos/examples/server/FRACTAL_TASKS_DIR/.fractal/apx_fractal_task_collection0.1.1/venv/lib/python3.10/site-packages/zarr/core.py", line 252, in _load_metadata_nosync
140+
meta_bytes = self._store[mkey]
141+
File "/redacted/fractal-demos/examples/server/FRACTAL_TASKS_DIR/.fractal/apx_fractal_task_collection0.1.1/venv/lib/python3.10/site-packages/zarr/storage.py", line 1113, in __getitem__
142+
raise KeyError(key)
143+
KeyError: '.zarray'
144+
145+
During handling of the above exception, another exception occurred:
146+
147+
Traceback (most recent call last):
148+
File "/redacted/fractal-demos/examples/server/FRACTAL_TASKS_DIR/.fractal/apx_fractal_task_collection0.1.1/venv/lib/python3.10/site-packages/apx_fractal_task_collection/measure_features.py", line 299, in <module>
149+
run_fractal_task(
150+
File "/redacted/fractal-demos/examples/server/FRACTAL_TASKS_DIR/.fractal/apx_fractal_task_collection0.1.1/venv/lib/python3.10/site-packages/fractal_tasks_core/tasks/_utils.py", line 79, in run_fractal_task
151+
metadata_update = task_function(**pars)
152+
File "pydantic/decorator.py", line 40, in pydantic.decorator.validate_arguments.validate.wrapper_function
153+
File "pydantic/decorator.py", line 134, in pydantic.decorator.ValidatedFunction.call
154+
File "pydantic/decorator.py", line 206, in pydantic.decorator.ValidatedFunction.execute
155+
File "/redacted/fractal-demos/examples/server/FRACTAL_TASKS_DIR/.fractal/apx_fractal_task_collection0.1.1/venv/lib/python3.10/site-packages/apx_fractal_task_collection/measure_features.py", line 211, in measure_features
156+
label_image = da.from_zarr(
157+
File "/redacted/fractal-demos/examples/server/FRACTAL_TASKS_DIR/.fractal/apx_fractal_task_collection0.1.1/venv/lib/python3.10/site-packages/dask/array/core.py", line 3599, in from_zarr
158+
z = zarr.Array(store, read_only=True, path=component, **kwargs)
159+
File "/redacted/fractal-demos/examples/server/FRACTAL_TASKS_DIR/.fractal/apx_fractal_task_collection0.1.1/venv/lib/python3.10/site-packages/zarr/core.py", line 224, in __init__
160+
self._load_metadata()
161+
File "/redacted/fractal-demos/examples/server/FRACTAL_TASKS_DIR/.fractal/apx_fractal_task_collection0.1.1/venv/lib/python3.10/site-packages/zarr/core.py", line 243, in _load_metadata
162+
self._load_metadata_nosync()
163+
File "/redacted/fractal-demos/examples/server/FRACTAL_TASKS_DIR/.fractal/apx_fractal_task_collection0.1.1/venv/lib/python3.10/site-packages/zarr/core.py", line 254, in _load_metadata_nosync
164+
raise ArrayNotFoundError(self._path)
165+
zarr.errors.ArrayNotFoundError: array not found at path %r' ''`;
166+
const parts = extractJobErrorParts(log);
167+
expect(parts.length).eq(3);
168+
expect(parts[0].text).eq('TASK ERROR:Task id: 1400 (Measure Features), e.workflow_task_order=10');
169+
expect(parts[0].highlight).eq(true);
170+
expect(parts[1].highlight).eq(false);
171+
expect(parts[2].text).eq("zarr.errors.ArrayNotFoundError: array not found at path %r' ''");
172+
expect(parts[2].highlight).eq(true);
173+
});
174+
175+
it('detect parts of an unknown error without traceback', () => {
176+
const log = `UNKNOWN ERROR
177+
Original error: something went really wrong`;
178+
const parts = extractJobErrorParts(log);
179+
expect(parts.length).eq(2);
180+
expect(parts[0].text).eq('UNKNOWN ERROR');
181+
expect(parts[0].highlight).eq(true);
182+
expect(parts[1].text).eq('Original error: something went really wrong');
183+
expect(parts[1].highlight).eq(false);
184+
});
185+
186+
it('detect parts of an unknown error with traceback', () => {
187+
const log = `UNKNOWN ERROR
188+
Original error: Traceback (most recent call last):
189+
File "/redacted/anaconda3/envs/fractal-server-1.4.0/lib/python3.10/site-packages/fractal_server/app/runner/_common.py", line 281, in call_single_task
190+
raise e
191+
File "/redacted/anaconda3/envs/fractal-server-1.4.0/lib/python3.10/site-packages/fractal_server/app/runner/_common.py", line 274, in call_single_task
192+
_call_command_wrapper(
193+
File "/redacted/anaconda3/envs/fractal-server-1.4.0/lib/python3.10/site-packages/fractal_server/app/runner/_common.py", line 184, in _call_command_wrapper
194+
raise TaskExecutionError(err)
195+
fractal_server.app.runner.common.TaskExecutionError: Traceback (most recent call last):
196+
File "/redacted/apx_fractal_task_collection/src/apx_fractal_task_collection/multiplexed_pixel_clustering.py", line 26, in <module>
197+
from sklearn.preprocessing import StandardScalerfrom, PowerTransformer
198+
ImportError: cannot import name 'StandardScalerfrom' from 'sklearn.preprocessing' (/redacted/.conda/envs/custom_fractal_task_collection/lib/python3.10/site-packages/sklearn/preprocessing/__init__.py)`;
199+
const parts = extractJobErrorParts(log);
200+
expect(parts.length).eq(3);
201+
expect(parts[0].text).eq('UNKNOWN ERROR');
202+
expect(parts[0].highlight).eq(true);
203+
expect(parts[1].highlight).eq(false);
204+
expect(parts[2].text).eq(
205+
"ImportError: cannot import name 'StandardScalerfrom' from 'sklearn.preprocessing' (/redacted/.conda/envs/custom_fractal_task_collection/lib/python3.10/site-packages/sklearn/preprocessing/__init__.py)"
206+
);
207+
expect(parts[2].highlight).eq(true);
208+
});
209+
210+
it('handles null or undefined inputs', () => {
211+
expect(extractJobErrorParts(null)).toHaveLength(0);
212+
expect(extractJobErrorParts()).toHaveLength(0);
213+
expect(extractRelevantJobError(null)).eq('');
214+
expect(extractRelevantJobError()).eq('');
215+
});

playwright.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export default defineConfig({
3939

4040
webServer: [
4141
{
42-
command: './tests/start-test-server.sh 1.4.3a2',
42+
command: './tests/start-test-server.sh 1.4.3',
4343
port: 8000,
4444
waitForPort: true,
4545
stdout: 'pipe',

src/lib/common/job_utilities.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* Split the error of a failed workflow job into multiple parts, marking the relevents ones,
3+
* so that they can be extracted or highlighted in a different way in the UI.
4+
*
5+
* @param {string|null} log
6+
* @returns {Array<{text: string, highlight: boolean}>}
7+
*/
8+
export function extractJobErrorParts(log) {
9+
if (!log) {
10+
return [];
11+
}
12+
log = log.trim();
13+
if (
14+
log.startsWith('TASK ERROR') ||
15+
log.startsWith('JOB ERROR') ||
16+
log.startsWith('UNKNOWN ERROR')
17+
) {
18+
const lines = log.split('\n');
19+
if (lines.length > 1) {
20+
const [firstLine, ...nextLines] = lines;
21+
return [{ text: firstLine, highlight: true }, ...extractTraceback(nextLines.join('\n'))];
22+
}
23+
}
24+
return [{ text: log, highlight: false }];
25+
}
26+
27+
const completeTracebackLine = 'Traceback (most recent call last):';
28+
29+
/**
30+
* @param {string} error
31+
*/
32+
function extractTraceback(error) {
33+
if (error.includes(completeTracebackLine)) {
34+
return extractCompleteTraceback(error);
35+
}
36+
return extractUppercaseTraceback(error);
37+
}
38+
39+
/**
40+
* @param {string} error
41+
* @returns {Array<{text: string, highlight: boolean}>}
42+
*/
43+
function extractCompleteTraceback(error) {
44+
const index = error.lastIndexOf('Traceback (most recent call last):');
45+
if (index !== -1) {
46+
const firstPart = error.substring(0, index);
47+
const lastPart = error.substring(index);
48+
let relevantErrorStarted = false;
49+
let part = firstPart;
50+
const parts = [];
51+
const lines = lastPart.split('\n');
52+
for (let i = 0; i < lines.length; i++) {
53+
const line = lines[i];
54+
if (i > 0 && !relevantErrorStarted && !line.startsWith(' ') && !line.startsWith('\t')) {
55+
parts.push({ text: part.trim(), highlight: false });
56+
relevantErrorStarted = true;
57+
part = '';
58+
}
59+
part += line + '\n';
60+
}
61+
parts.push({ text: part.trim(), highlight: true });
62+
return parts;
63+
}
64+
return [{ text: error.trim(), highlight: false }];
65+
}
66+
67+
/**
68+
* @param {string} error
69+
* @returns {Array<{text: string, highlight: boolean}>}
70+
*/
71+
function extractUppercaseTraceback(error) {
72+
const uppercaseTraceback = 'TRACEBACK:';
73+
const tracebackIndex = error.indexOf(uppercaseTraceback);
74+
if (tracebackIndex !== -1) {
75+
return [
76+
{ text: error.substring(tracebackIndex, uppercaseTraceback.length), highlight: false },
77+
{ text: error.substring(tracebackIndex + uppercaseTraceback.length + 1), highlight: true }
78+
];
79+
}
80+
return [{ text: error.trim(), highlight: false }];
81+
}
82+
83+
/**
84+
* @param {string|null} completeJobError
85+
* @param {number|undefined} maxLines
86+
* @returns {string}
87+
*/
88+
export function extractRelevantJobError(completeJobError, maxLines = undefined) {
89+
if (!completeJobError) {
90+
return '';
91+
}
92+
const relevantParts = extractJobErrorParts(completeJobError).filter((p) => p.highlight);
93+
let relevantError;
94+
if (relevantParts.length === 0) {
95+
relevantError = completeJobError;
96+
} else {
97+
relevantError = relevantParts.map((p) => p.text).join('\n');
98+
}
99+
if (!maxLines) {
100+
return relevantError;
101+
}
102+
const lines = relevantError.split('\n');
103+
if (lines.length > maxLines) {
104+
const truncatedErrorLines = [];
105+
for (let i = 0; i < maxLines; i++) {
106+
truncatedErrorLines.push(lines[i]);
107+
}
108+
truncatedErrorLines.push('[...]');
109+
return truncatedErrorLines.join('\n');
110+
}
111+
return relevantError;
112+
}

0 commit comments

Comments
 (0)