Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add job metrics per invocation #19048

Merged
merged 6 commits into from
Nov 6, 2024
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
5 changes: 5 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"dom-to-image": "^2.6.0",
"dompurify": "^3.0.6",
"dumpmeta-webpack-plugin": "^0.2.0",
"echarts": "^5.5.1",
"elkjs": "^0.8.2",
"file-saver": "^2.0.5",
"flush-promises": "^1.0.2",
Expand Down Expand Up @@ -100,7 +101,11 @@
"tus-js-client": "^3.1.1",
"underscore": "^1.13.6",
"util": "^0.12.5",
"vega": "^5.30.0",
"vega-embed": "^6.26.0",
"vega-lite": "^5.21.0",
Comment on lines +104 to +106
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is only needed in a few places, and quite a large package, could we load this on demand?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose if you ask then it is possible 😃 ? I don't know how to do that though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can push a commit if you like

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To explain what's happening here, to anyone curious:

  • the vega libs were added to "modulesExcludedFromLibs" so it's not bundled into the libs bundle. This adds these libs to their own bundles.
  • type imports were changes from import { type ...} to import type { ... }. These are not equivalent. The second one let's typescript completely remove the import line, while the first keeps an empty import, in case of import side effects. This would break on demand loading
  • Finally the component is imported as const VegaWrapper = () => import("./VegaWrapper.vue");. This is a way to lazy load vue components and all it's related code (the vega library in this case). The component can be used like before

"vue": "^2.7.14",
"vue-echarts": "^7.0.3",
"vue-infinite-scroll": "^2.0.2",
"vue-multiselect": "^2.1.7",
"vue-observe-visibility": "^1.0.0",
Expand Down
104 changes: 104 additions & 0 deletions client/src/api/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -18317,6 +18334,49 @@ 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;
/** Step Index */
step_index: number;
/** Step Label */
step_label: string | null;
/**
* 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 */
Expand Down Expand Up @@ -27021,6 +27081,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;
Expand Down
49 changes: 49 additions & 0 deletions client/src/components/WorkflowInvocationState/VegaWrapper.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<template>
<div ref="chartContainer" class="chart"></div>
</template>

<script setup lang="ts">
import { useResizeObserver } from "@vueuse/core";
import embed, { type VisualizationSpec } from "vega-embed";
import { onBeforeUnmount, onMounted, ref, watch } from "vue";

export interface VisSpec {
spec: VisualizationSpec;
}

const props = defineProps<VisSpec>();

const chartContainer = ref<HTMLDivElement | null>(null);
let vegaView: any;

async function embedChart() {
if (vegaView) {
vegaView.finalize();
}
if (chartContainer.value !== null) {
const result = await embed(chartContainer.value, props.spec, { renderer: "svg" });
vegaView = result.view;
}
}

onMounted(embedChart);

watch(props, embedChart, { immediate: true, deep: true });
useResizeObserver(chartContainer, () => {
embedChart();
});

// Cleanup the chart when the component is unmounted
onBeforeUnmount(() => {
if (vegaView) {
vegaView.finalize();
}
});
</script>

<style scoped>
.chart {
width: 100%;
height: 100%;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<script setup lang="ts">
import type { VisualizationSpec } from "vega-embed";
import { computed, ref, watch } from "vue";
import { type ComputedRef } from "vue";

import { type components, GalaxyApi } from "@/api";
import { errorMessageAsString } from "@/utils/simple-error";

const VegaWrapper = () => import("./VegaWrapper.vue");

const props = defineProps({
invocationId: {
type: String,
required: true,
},
});

const groupBy = ref<"tool_id" | "step_id">("tool_id");
const jobMetrics = ref<components["schemas"]["WorkflowJobMetric"][]>();
const fetchError = ref<string>();

const attributeToLabel = {
tool_id: "Tool ID",
step_id: "Step",
};

async function fetchMetrics() {
const { data, error } = await GalaxyApi().GET("/api/invocations/{invocation_id}/metrics", {
params: {
path: {
invocation_id: props.invocationId,
},
},
});
if (error) {
fetchError.value = errorMessageAsString(error);
} else {
jobMetrics.value = data;
}
}

watch(props, () => fetchMetrics(), { immediate: true });

function itemToX(item: components["schemas"]["WorkflowJobMetric"]) {
if (groupBy.value === "tool_id") {
return item.tool_id;
} else if (groupBy.value === "step_id") {
return `${item.step_index + 1}: ${item.step_label || item.tool_id}`;
} else {
throw Error("Cannot happen");
}
}

interface boxplotData {
x_title: string;
y_title: string;
values?: { x: string; y: Number }[];
}

function metricToSpecData(
jobMetrics: components["schemas"]["WorkflowJobMetric"][] | undefined,
metricName: string,
yTitle: string,
transform?: (param: number) => number
) {
const wallclock = jobMetrics?.filter((jobMetric) => jobMetric.name == metricName);
const values = wallclock?.map((item) => {
let y = parseFloat(item.raw_value);
if (transform !== undefined) {
y = transform(y);
}
return {
y,
x: itemToX(item),
};
});
return {
x_title: attributeToLabel[groupBy.value],
y_title: yTitle,
values,
};
}

const wallclock: ComputedRef<boxplotData> = computed(() => {
return metricToSpecData(jobMetrics.value, "runtime_seconds", "Runtime (in Seconds)");
});

const coresAllocated: ComputedRef<boxplotData> = computed(() => {
return metricToSpecData(jobMetrics.value, "galaxy_slots", "Cores Allocated");
});

const memoryAllocated: ComputedRef<boxplotData> = computed(() => {
return metricToSpecData(jobMetrics.value, "galaxy_memory_mb", "Memory Allocated (in MB)");
});

const peakMemory: ComputedRef<boxplotData> = computed(() => {
return metricToSpecData(jobMetrics.value, "memory.peak", "Max memory usage recorded (in MB)", (v) => v / 1024 ** 2);
});

function itemToSpec(item: boxplotData) {
const spec: VisualizationSpec = {
$schema: "https://vega.github.io/schema/vega-lite/v5.json",
description: "A boxplot with jittered points.",
data: {
values: item.values!,
},
transform: [
{
calculate: "random() - 0.5",
as: "random_jitter",
},
],
layer: [
{
mark: { type: "boxplot", opacity: 0.5 },
encoding: {
x: { field: "x", type: "nominal" },
y: { field: "y", type: "quantitative" },
},
width: "container",
},
{
mark: {
type: "point",
opacity: 0.7,
},
encoding: {
x: {
field: "x",
type: "nominal",
title: item.x_title,
axis: {
labelAngle: -45,
labelAlign: "right",
},
},
xOffset: { field: "random_jitter", type: "quantitative", scale: { domain: [-2, 2] } },
y: {
field: "y",
type: "quantitative",
scale: { zero: false },
title: item.y_title,
},
},
width: "container",
},
],
};
return spec;
}

const specs = computed(() => {
const items = [wallclock.value, coresAllocated.value, memoryAllocated.value, peakMemory.value].filter(
(item) => item.values?.length
);
const specs = Object.fromEntries(items.map((item) => [item.y_title, itemToSpec(item)]));
return specs;
});
</script>

<template>
<div>
<b-tabs lazy>
<b-tab title="Summary by Tool" @click="groupBy = 'tool_id'">
<div v-for="(spec, key) in specs" :key="key">
<h2 class="h-l truncate text-center">{{ key }}</h2>
<VegaWrapper :spec="spec" />
</div>
</b-tab>
<b-tab title="Summary by Workflow Step" @click="groupBy = 'step_id'">
<div v-for="(spec, key) in specs" :key="key">
<h2 class="h-l truncate text-center">{{ key }}</h2>
<VegaWrapper :spec="spec" />
</div>
</b-tab>
</b-tabs>
</div>
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import InvocationReport from "../Workflow/InvocationReport.vue";
import WorkflowInvocationExportOptions from "./WorkflowInvocationExportOptions.vue";
import WorkflowInvocationHeader from "./WorkflowInvocationHeader.vue";
import WorkflowInvocationInputOutputTabs from "./WorkflowInvocationInputOutputTabs.vue";
import WorkflowInvocationMetrics from "./WorkflowInvocationMetrics.vue";
import WorkflowInvocationOverview from "./WorkflowInvocationOverview.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";

Expand Down Expand Up @@ -185,6 +186,9 @@ function cancelWorkflowSchedulingLocal() {
<LoadingSpan message="Waiting to complete invocation" />
</BAlert>
</BTab>
<BTab title="Metrics" :lazy="true">
<WorkflowInvocationMetrics :invocation-id="invocation.id"></WorkflowInvocationMetrics>
</BTab>
</BTabs>
</div>
<BAlert v-else-if="errorMessage" variant="danger" show>
Expand Down
Loading
Loading