diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 93d99b75fa0a..73cb76dd7af6 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -2594,6 +2594,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/invocations/{invocation_id}/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Invocation Metrics */ + get: operations["get_invocation_metrics_api_invocations__invocation_id__metrics_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/invocations/{invocation_id}/prepare_store_download": { parameters: { query?: never; @@ -18317,6 +18334,45 @@ export interface components { [key: string]: number; }; }; + /** + * WorkflowJobMetric + * @example { + * "name": "start_epoch", + * "plugin": "core", + * "raw_value": "1614261340.0000000", + * "title": "Job Start Time", + * "value": "2021-02-25 14:55:40" + * } + */ + WorkflowJobMetric: { + /** + * Name + * @description The name of the metric variable. + */ + name: string; + /** + * Plugin + * @description The instrumenter plugin that generated this metric. + */ + plugin: string; + /** + * Raw Value + * @description The raw value of the metric as a string. + */ + raw_value: string; + /** + * Title + * @description A descriptive title for this metric. + */ + title: string; + /** Tool Id */ + tool_id: string; + /** + * Value + * @description The textual representation of the metric value. + */ + value: string; + }; /** WorkflowLandingRequest */ WorkflowLandingRequest: { /** Request State */ @@ -27021,6 +27077,50 @@ export interface operations { }; }; }; + get_invocation_metrics_api_invocations__invocation_id__metrics_get: { + parameters: { + query?: never; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path: { + /** @description The encoded database identifier of the Invocation. */ + invocation_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowJobMetric"][]; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; prepare_store_download_api_invocations__invocation_id__prepare_store_download_post: { parameters: { query?: never; diff --git a/client/src/components/WorkflowInvocationState/MetricsBoxPlots.vue b/client/src/components/WorkflowInvocationState/MetricsBoxPlots.vue new file mode 100644 index 000000000000..1876190cc903 --- /dev/null +++ b/client/src/components/WorkflowInvocationState/MetricsBoxPlots.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/client/src/components/WorkflowInvocationState/WorkflowInvocationMetrics.vue b/client/src/components/WorkflowInvocationState/WorkflowInvocationMetrics.vue new file mode 100644 index 000000000000..cb9aed99233f --- /dev/null +++ b/client/src/components/WorkflowInvocationState/WorkflowInvocationMetrics.vue @@ -0,0 +1,52 @@ + + + diff --git a/client/src/components/WorkflowInvocationState/WorkflowInvocationState.vue b/client/src/components/WorkflowInvocationState/WorkflowInvocationState.vue index e035bc9b7dbe..bcf03f61b816 100644 --- a/client/src/components/WorkflowInvocationState/WorkflowInvocationState.vue +++ b/client/src/components/WorkflowInvocationState/WorkflowInvocationState.vue @@ -13,6 +13,7 @@ import { isTerminal, jobCount, runningCount } from "./util"; import InvocationReport from "../Workflow/InvocationReport.vue"; import WorkflowInvocationExportOptions from "./WorkflowInvocationExportOptions.vue"; +import WorkflowInvocationMetrics from "./WorkflowInvocationMetrics.vue"; import WorkflowInvocationHeader from "./WorkflowInvocationHeader.vue"; import WorkflowInvocationInputOutputTabs from "./WorkflowInvocationInputOutputTabs.vue"; import WorkflowInvocationOverview from "./WorkflowInvocationOverview.vue"; @@ -185,6 +186,9 @@ function cancelWorkflowSchedulingLocal() { + + + diff --git a/lib/galaxy/managers/jobs.py b/lib/galaxy/managers/jobs.py index 1d4a61dac6f0..be9d0083567b 100644 --- a/lib/galaxy/managers/jobs.py +++ b/lib/galaxy/managers/jobs.py @@ -54,6 +54,7 @@ ImplicitCollectionJobs, ImplicitCollectionJobsJobAssociation, Job, + JobMetricNumeric, JobParameter, User, Workflow, @@ -729,6 +730,38 @@ def invocation_job_source_iter(sa_session, invocation_id): yield ("ImplicitCollectionJobs", row[1], row[2]) +def get_job_metrics_for_invocation(sa_session: galaxy_scoped_session, invocation_id: int): + single_job_stmnt = ( + select(JobMetricNumeric, Job.tool_id) + .join(Job, JobMetricNumeric.job_id == Job.id) + .join( + WorkflowInvocationStep, + and_( + WorkflowInvocationStep.workflow_invocation_id == invocation_id, WorkflowInvocationStep.job_id == Job.id + ), + ) + ) + collection_job_stmnt = ( + select(JobMetricNumeric, Job.tool_id) + .join(Job, JobMetricNumeric.job_id == Job.id) + .join(ImplicitCollectionJobsJobAssociation, Job.id == ImplicitCollectionJobsJobAssociation.job_id) + .join( + ImplicitCollectionJobs, + ImplicitCollectionJobs.id == ImplicitCollectionJobsJobAssociation.implicit_collection_jobs_id, + ) + .join( + WorkflowInvocationStep, + and_( + WorkflowInvocationStep.workflow_invocation_id == invocation_id, + WorkflowInvocationStep.implicit_collection_jobs_id == ImplicitCollectionJobs.id, + ), + ) + ) + # should be sa_session.execute(single_job_stmnt.union(collection_job_stmnt)).all() but that returns + # columns instead of the job metrics ORM instance. + return list(sa_session.execute(single_job_stmnt).all()) + list(sa_session.execute(collection_job_stmnt).all()) + + def fetch_job_states(sa_session, job_source_ids, job_source_types): assert len(job_source_ids) == len(job_source_types) job_ids = set() @@ -911,6 +944,10 @@ def summarize_job_metrics(trans, job): Precondition: the caller has verified the job is accessible to the user represented by the trans parameter. """ + return summarize_metrics(trans, job.metrics) + + +def summarize_metrics(trans: ProvidesUserContext, job_metrics): safety_level = Safety.SAFE if trans.user_is_admin: safety_level = Safety.UNSAFE @@ -922,7 +959,7 @@ def summarize_job_metrics(trans, job): m.metric_value, m.plugin, ) - for m in job.metrics + for m in job_metrics ] dictifiable_metrics = trans.app.job_metrics.dictifiable_metrics(raw_metrics, safety_level) return [d.dict() for d in dictifiable_metrics] diff --git a/lib/galaxy/schema/schema.py b/lib/galaxy/schema/schema.py index 0d9646316e59..88460a3f425f 100644 --- a/lib/galaxy/schema/schema.py +++ b/lib/galaxy/schema/schema.py @@ -2171,6 +2171,10 @@ class JobMetric(Model): ) +class WorkflowJobMetric(JobMetric): + tool_id: str + + class JobMetricCollection(RootModel): """Represents a collection of metrics associated with a Job.""" diff --git a/lib/galaxy/webapps/galaxy/api/workflows.py b/lib/galaxy/webapps/galaxy/api/workflows.py index c4abb88968c0..e3604058bc4f 100644 --- a/lib/galaxy/webapps/galaxy/api/workflows.py +++ b/lib/galaxy/webapps/galaxy/api/workflows.py @@ -78,6 +78,7 @@ ShareWithPayload, ShareWithStatus, SharingStatus, + WorkflowJobMetric, WorkflowLandingRequest, WorkflowSortByEnum, ) @@ -1759,3 +1760,11 @@ def workflow_invocation_jobs_summary( ) -> InvocationJobsResponse: """An alias for `GET /api/invocations/{invocation_id}/jobs_summary`. `workflow_id` is ignored.""" return self.invocation_jobs_summary(trans=trans, invocation_id=invocation_id) + + @router.get("/api/invocations/{invocation_id}/metrics") + def get_invocation_metrics( + self, + invocation_id: InvocationIDPathParam, + trans: ProvidesUserContext = DependsOnTrans, + ) -> List[WorkflowJobMetric]: + return self.invocations_service.show_invocation_metrics(trans=trans, invocation_id=invocation_id) diff --git a/lib/galaxy/webapps/galaxy/services/invocations.py b/lib/galaxy/webapps/galaxy/services/invocations.py index ce9610ac0422..4066a3dbfeaa 100644 --- a/lib/galaxy/webapps/galaxy/services/invocations.py +++ b/lib/galaxy/webapps/galaxy/services/invocations.py @@ -25,7 +25,9 @@ from galaxy.managers.histories import HistoryManager from galaxy.managers.jobs import ( fetch_job_states, + get_job_metrics_for_invocation, invocation_job_source_iter, + summarize_metrics, ) from galaxy.managers.workflows import WorkflowsManager from galaxy.model import ( @@ -162,6 +164,18 @@ def show_invocation_step(self, trans, step_id) -> InvocationStep: ) return self.serialize_workflow_invocation_step(wfi_step) + def show_invocation_metrics(self, trans: ProvidesHistoryContext, invocation_id: int): + extended_job_metrics = get_job_metrics_for_invocation(trans.sa_session, invocation_id) + job_metrics = [] + tool_ids = [] + for row in extended_job_metrics: + job_metrics.append(row[0]) + tool_ids.append(row[1]) + metrics_dict_list = summarize_metrics(trans, job_metrics) + for tool_id, metrics_dict in zip(tool_ids, metrics_dict_list): + metrics_dict["tool_id"] = tool_id + return metrics_dict_list + def update_invocation_step(self, trans, step_id, action): wfi_step = self._workflows_manager.update_invocation_step(trans, step_id, action) return self.serialize_workflow_invocation_step(wfi_step) diff --git a/lib/galaxy_test/api/test_workflows.py b/lib/galaxy_test/api/test_workflows.py index 174985ad51d9..f18cdd8a760d 100644 --- a/lib/galaxy_test/api/test_workflows.py +++ b/lib/galaxy_test/api/test_workflows.py @@ -3499,6 +3499,39 @@ def test_workflow_new_autocreated_history(self): invocation_id = run_workflow_dict["id"] self.workflow_populator.wait_for_invocation_and_jobs(new_history_id, workflow_id, invocation_id) + def test_invocation_job_metrics_simple(self): + with self.dataset_populator.test_history() as history_id: + summary = self._run_workflow(WORKFLOW_SIMPLE, test_data={"input1": "hello world"}, history_id=history_id) + self.workflow_populator.wait_for_invocation_and_jobs( + history_id=history_id, workflow_id=summary.workflow_id, invocation_id=summary.invocation_id + ) + job_metrics = self._get(f"invocations/{summary.invocation_id}/metrics").json() + galaxy_slots = [m for m in job_metrics if m["name"] == "galaxy_slots"] + assert len(galaxy_slots) == 1 + + def test_invocation_job_metrics_map_over(self): + with self.dataset_populator.test_history() as history_id: + summary = self._run_workflow( + WORKFLOW_SIMPLE, + test_data={ + "input1": { + "collection_type": "list", + "name": "the_dataset_list", + "elements": [ + {"identifier": "el1", "value": "1.fastq", "type": "File"}, + {"identifier": "el2", "value": "1.fastq", "type": "File"}, + ], + } + }, + history_id=history_id, + ) + self.workflow_populator.wait_for_invocation_and_jobs( + history_id=history_id, workflow_id=summary.workflow_id, invocation_id=summary.invocation_id + ) + job_metrics = self._get(f"invocations/{summary.invocation_id}/metrics").json() + galaxy_slots = [m for m in job_metrics if m["name"] == "galaxy_slots"] + assert len(galaxy_slots) == 2 + def test_workflow_output_dataset(self): with self.dataset_populator.test_history() as history_id: summary = self._run_workflow(WORKFLOW_SIMPLE, test_data={"input1": "hello world"}, history_id=history_id)