From e64628d73f4a091a9ad6b1e5e6a6eec07b0b8216 Mon Sep 17 00:00:00 2001 From: Kavitha Conjeevaram Mohan Date: Tue, 1 Nov 2022 14:09:01 -0700 Subject: [PATCH] Metrics api events (#1214) * Add schema to router in events Signed-off-by: Kavitha Conjeevaram Mohan * update Saved Visualization in backend to include metrics Signed-off-by: Kavitha Conjeevaram Mohan * add SelectedLabels class Signed-off-by: Kavitha Conjeevaram Mohan * Add Token class in saved visualization Signed-off-by: Kavitha Conjeevaram Mohan * update test Signed-off-by: Kavitha Conjeevaram Mohan * fix frontend API data Signed-off-by: Kavitha Conjeevaram Mohan * update default subtype state to viz Signed-off-by: Kavitha Conjeevaram Mohan * disabling labels on front end Signed-off-by: Kavitha Conjeevaram Mohan * remove console.log comments Signed-off-by: Kavitha Conjeevaram Mohan * remove logger comments Signed-off-by: Kavitha Conjeevaram Mohan Signed-off-by: Kavitha Conjeevaram Mohan --- .../components/common/search/search.test.tsx | 6 + .../components/common/search/search.tsx | 7 + .../event_analytics/explorer/explorer.tsx | 12 ++ .../save_panel/__tests__/save_panel.test.tsx | 8 +- .../explorer/save_panel/save_panel.tsx | 21 +- .../event_analytics/saved_objects.ts | 21 ++ .../event_analytics/event_analytics_router.ts | 10 + opensearch-observability/build.gradle | 2 +- .../observability/model/SavedVisualization.kt | 191 +++++++++++++++++- .../model/SavedVisualizationTests.kt | 11 +- 10 files changed, 277 insertions(+), 12 deletions(-) diff --git a/dashboards-observability/public/components/common/search/search.test.tsx b/dashboards-observability/public/components/common/search/search.test.tsx index 6a7543fec..f204369c4 100644 --- a/dashboards-observability/public/components/common/search/search.test.tsx +++ b/dashboards-observability/public/components/common/search/search.test.tsx @@ -33,6 +33,7 @@ describe('Search bar', () => { const popoverItems = jest.fn(); const isLiveTailOn = jest.fn(); const countDistribution = jest.fn(); + const setMetricLabel = jest.fn(); const utils = render( { popoverItems={popoverItems} isLiveTailOn={isLiveTailOn} countDistribution={countDistribution} + curVisId={'line'} + spanValue={false} + setSubType={'metric'} + setMetricMeasure={'hours (h)'} + setMetricLabel={setMetricLabel} /> ); diff --git a/dashboards-observability/public/components/common/search/search.tsx b/dashboards-observability/public/components/common/search/search.tsx index a714dfa94..d726c9b80 100644 --- a/dashboards-observability/public/components/common/search/search.tsx +++ b/dashboards-observability/public/components/common/search/search.tsx @@ -85,6 +85,9 @@ export const Search = (props: any) => { searchError = null, curVisId, spanValue, + setSubType, + setMetricMeasure, + setMetricLabel, } = props; const appLogEvents = tabId.match(APP_ANALYTICS_TAB_ID_REGEX); @@ -216,9 +219,13 @@ export const Search = (props: any) => { showOptionList={ showSavePanelOptionsList && searchBarConfigs[selectedSubTabId]?.showSavePanelOptionsList + } curVisId={curVisId} spanValue={spanValue} + setSubType={setSubType} + setMetricMeasure={setMetricMeasure} + setMetricLabel={setMetricLabel} /> diff --git a/dashboards-observability/public/components/event_analytics/explorer/explorer.tsx b/dashboards-observability/public/components/event_analytics/explorer/explorer.tsx index d63dee0c3..d2c3ff241 100644 --- a/dashboards-observability/public/components/event_analytics/explorer/explorer.tsx +++ b/dashboards-observability/public/components/event_analytics/explorer/explorer.tsx @@ -157,6 +157,9 @@ export const Explorer = ({ false ); const [spanValue, setSpanValue] = useState(false); + const [subType, setSubType] = useState('visualization'); + const [metricMeasure, setMetricMeasure] = useState(''); + const [metricLabel, setMetricLabel] = useState([]); const queryRef = useRef(); const appBasedRef = useRef(''); appBasedRef.current = appBaseQuery; @@ -1107,6 +1110,9 @@ export const Explorer = ({ ? JSON.stringify(userVizConfigs[curVisId]) : JSON.stringify({}), description: vizDescription, + subType: subType, + unitsOfMeasure: metricMeasure, + // selectedLabels: metricLabel }) .then((res: any) => { setToast( @@ -1141,6 +1147,9 @@ export const Explorer = ({ ? JSON.stringify(userVizConfigs[curVisId]) : JSON.stringify({}), description: vizDescription, + subType: subType, + unitsOfMeasure: metricMeasure, + // selectedLabels: metricLabel }) .then((res: any) => { batch(() => { @@ -1342,6 +1351,9 @@ export const Explorer = ({ searchError={explorerVisualizations} curVisId={curVisId} spanValue={spanValue} + setSubType={setSubType} + setMetricMeasure={setMetricMeasure} + setMetricLabel={setMetricLabel} /> { it('Renders saved query table', async () => { const handleNameChange = jest.fn(); const handleOptionChange = jest.fn(); + const setMetricLabel = jest.fn(); const savedObjects = new SavedObjects(httpClientMock); const wrapper = mount( @@ -28,7 +29,12 @@ describe('Saved query table component', () => { savedObjects={savedObjects} savePanelName={'Count by depature'} showOptionList={true} - /> + curVisId={'line'} + spanValue={false} + setSubType={'metric'} + setMetricMeasure={'hours (h)'} + setMetricLabel={setMetricLabel} + /> ); wrapper.update(); diff --git a/dashboards-observability/public/components/event_analytics/explorer/save_panel/save_panel.tsx b/dashboards-observability/public/components/event_analytics/explorer/save_panel/save_panel.tsx index e8c8f5b8e..5f132bab9 100644 --- a/dashboards-observability/public/components/event_analytics/explorer/save_panel/save_panel.tsx +++ b/dashboards-observability/public/components/event_analytics/explorer/save_panel/save_panel.tsx @@ -27,6 +27,9 @@ interface ISavedPanelProps { showOptionList: boolean; curVisId: string; spanValue: boolean; + setSubType: any; + setMetricMeasure: any; + setMetricLabel: any; } interface CustomPanelOptions { @@ -45,6 +48,9 @@ export const SavePanel = ({ showOptionList, curVisId, spanValue, + setSubType, + setMetricMeasure, + setMetricLabel, }: ISavedPanelProps) => { const [options, setOptions] = useState([]); const [checked, setChecked] = useState(false); @@ -67,14 +73,21 @@ export const SavePanel = ({ const onToggleChange = (e: { target: { checked: React.SetStateAction } }) => { setChecked(e.target.checked); + if (e.target.checked) { + setSubType("metric") + } else { + setSubType("visualization") + } }; const onMeasureChange = (selectedMeasures: React.SetStateAction) => { setMeasure(selectedMeasures); + setMetricMeasure(selectedMeasures[0].label); }; const onLabelChange = (selectedLabels: React.SetStateAction) => { setLabel(selectedLabels); + setMetricLabel(selectedLabels); }; return ( @@ -149,11 +162,11 @@ export const SavePanel = ({ data-test-subj="eventExplorer__metricMeasureSaveComboBox" /> - + {/*

{'Labels'}

-
- + */} + {/* - + */} )} diff --git a/dashboards-observability/public/services/saved_objects/event_analytics/saved_objects.ts b/dashboards-observability/public/services/saved_objects/event_analytics/saved_objects.ts index 880aad65c..0f207ab79 100644 --- a/dashboards-observability/public/services/saved_objects/event_analytics/saved_objects.ts +++ b/dashboards-observability/public/services/saved_objects/event_analytics/saved_objects.ts @@ -57,6 +57,9 @@ export default class SavedObjects { description = '', applicationId = '', userConfigs = '', + subType = '', + unitsOfMeasure = '', + selectedLabels, }: any) { const objRequest: any = { object: { @@ -91,6 +94,18 @@ export default class SavedObjects { objRequest.object.user_configs = userConfigs; } + if (!isEmpty(subType)) { + objRequest.object.sub_type = subType; + } + + if (!isEmpty(unitsOfMeasure)) { + objRequest.object.units_of_measure = unitsOfMeasure; + } + + if (!isEmpty(selectedLabels)) { + objRequest.object.selected_labels = selectedLabels; + } + return objRequest; } @@ -167,6 +182,9 @@ export default class SavedObjects { timestamp: params.timestamp, userConfigs: params.userConfigs, description: params.description, + subType: params.subType, + unitsOfMeasure: params.unitsOfMeasure, + selectedLabels: params.selectedLabels }); finalParams.object_id = params.objectId; @@ -227,6 +245,9 @@ export default class SavedObjects { applicationId: params.applicationId, userConfigs: params.userConfigs, description: params.description, + subType: params.subType, + unitsOfMeasure: params.unitsOfMeasure, + selectedLabels: params.selectedLabels }); return await this.http.post( diff --git a/dashboards-observability/server/routes/event_analytics/event_analytics_router.ts b/dashboards-observability/server/routes/event_analytics/event_analytics_router.ts index 4040e7e66..c2fd7c2d2 100644 --- a/dashboards-observability/server/routes/event_analytics/event_analytics_router.ts +++ b/dashboards-observability/server/routes/event_analytics/event_analytics_router.ts @@ -139,6 +139,11 @@ export const registerEventAnalyticsRouter = ({ description: schema.string(), application_id: schema.maybe(schema.string()), user_configs: schema.string(), + sub_type: schema.string(), + units_of_measure: schema.maybe(schema.string()), + selected_labels: schema.maybe(schema.object({ + label: schema.arrayOf(schema.object({}, { unknowns: 'allow' })), + })), }), }), }, @@ -226,6 +231,11 @@ export const registerEventAnalyticsRouter = ({ description: schema.string(), application_id: schema.maybe(schema.string()), user_configs: schema.string(), + sub_type: schema.string(), + units_of_measure: schema.maybe(schema.string()), + selected_labels: schema.maybe(schema.object({ + labels: schema.arrayOf(schema.object({}, { unknowns: 'allow' })), + })), }), }), }, diff --git a/opensearch-observability/build.gradle b/opensearch-observability/build.gradle index 92935fd3a..47fd7d2b5 100644 --- a/opensearch-observability/build.gradle +++ b/opensearch-observability/build.gradle @@ -10,7 +10,7 @@ import org.opensearch.gradle.testclusters.StandaloneRestIntegTestTask buildscript { ext { isSnapshot = "true" == System.getProperty("build.snapshot", "true") - opensearch_version = System.getProperty("opensearch.version", "2.2.0-SNAPSHOT") + opensearch_version = System.getProperty("opensearch.version", "2.4.0-SNAPSHOT") buildVersionQualifier = System.getProperty("build.version_qualifier", "") version_tokens = opensearch_version.tokenize('-') opensearch_build = version_tokens[0] + '.0' diff --git a/opensearch-observability/src/main/kotlin/org/opensearch/observability/model/SavedVisualization.kt b/opensearch-observability/src/main/kotlin/org/opensearch/observability/model/SavedVisualization.kt index 2c65d0471..608546c5c 100644 --- a/opensearch-observability/src/main/kotlin/org/opensearch/observability/model/SavedVisualization.kt +++ b/opensearch-observability/src/main/kotlin/org/opensearch/observability/model/SavedVisualization.kt @@ -43,7 +43,13 @@ import org.opensearch.observability.util.logger * "name": "Logs between dates", * "description": "some descriptions related to this query", * "application_id": "KE1Ie34BbsTr-CsB4G6Y", - * "user_configs": "{\"dataConfig\":\"{}\",\"layoutConfig\": \"{}\"}" + * "user_configs": "{\"dataConfig\":\"{}\",\"layoutConfig\": \"{}\"}", + * "sub_type": "metric", + * "units_of_measure: "hours (h)", + * "labels": [ + * {"label":"avg"}, + * {"label":"count"}, + * ] * } * } */ @@ -58,6 +64,9 @@ internal data class SavedVisualization( val selectedFields: SavedQuery.SelectedFields?, val applicationId: String? = null, val userConfigs: String? = null, + val subType: String?, + val unitsOfMeasure: String? = null, + val selectedLabels: SelectedLabels? = null, ) : BaseObjectData { internal companion object { @@ -71,6 +80,9 @@ internal data class SavedVisualization( private const val SELECTED_FIELDS_TAG = "selected_fields" private const val APPLICATION_ID_TAG = "application_id" private const val USER_CONFIGS_TAG = "user_configs" + private const val SUB_TYPE_TAG = "sub_type" + private const val UNITS_OF_MEASURE_TAG = "units_of_measure" + private const val SELECTED_LABELS_TAG = "selected_labels" /** * reader to create instance of class from writable. @@ -87,6 +99,7 @@ internal data class SavedVisualization( * @param parser data referenced at parser * @return created SavedVisualization object */ + @Suppress("ComplexMethod") fun parse(parser: XContentParser): SavedVisualization { var name: String? = null var description: String? = null @@ -97,6 +110,9 @@ internal data class SavedVisualization( var selectedFields: SavedQuery.SelectedFields? = null var applicationId: String? = null var userConfigs: String? = null + var subType: String? = null + var unitsOfMeasure: String? = null + var selectedLabels: SelectedLabels? = null XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.currentToken(), parser) while (XContentParser.Token.END_OBJECT != parser.nextToken()) { val fieldName = parser.currentName() @@ -111,6 +127,9 @@ internal data class SavedVisualization( SELECTED_FIELDS_TAG -> selectedFields = SavedQuery.SelectedFields.parse(parser) APPLICATION_ID_TAG -> applicationId = parser.text() USER_CONFIGS_TAG -> userConfigs = parser.text() + SUB_TYPE_TAG -> subType = parser.text() + UNITS_OF_MEASURE_TAG -> unitsOfMeasure = parser.text() + SELECTED_LABELS_TAG -> selectedLabels = SelectedLabels.parse(parser) else -> { parser.skipChildren() log.info("$LOG_PREFIX:SavedVisualization Skipping Unknown field $fieldName") @@ -126,7 +145,10 @@ internal data class SavedVisualization( selectedTimestamp, selectedFields, applicationId, - userConfigs + userConfigs, + subType, + unitsOfMeasure, + selectedLabels ) } } @@ -153,7 +175,10 @@ internal data class SavedVisualization( selectedTimestamp = input.readOptionalWriteable(SavedQuery.Token.reader), selectedFields = input.readOptionalWriteable(SavedQuery.SelectedFields.reader), applicationId = input.readOptionalString(), - userConfigs = input.readOptionalString() + userConfigs = input.readOptionalString(), + subType = input.readString(), + unitsOfMeasure = input.readOptionalString(), + selectedLabels = input.readOptionalWriteable(SelectedLabels.reader), ) /** @@ -169,6 +194,9 @@ internal data class SavedVisualization( output.writeOptionalWriteable(selectedFields) output.writeOptionalString(applicationId) output.writeOptionalString(userConfigs) + output.writeString(subType) + output.writeOptionalString(unitsOfMeasure) + output.writeOptionalWriteable(selectedLabels) } /** @@ -186,6 +214,163 @@ internal data class SavedVisualization( .fieldIfNotNull(SELECTED_FIELDS_TAG, selectedFields) .fieldIfNotNull(APPLICATION_ID_TAG, applicationId) .fieldIfNotNull(USER_CONFIGS_TAG, userConfigs) + .fieldIfNotNull(SUB_TYPE_TAG, subType) + .fieldIfNotNull(UNITS_OF_MEASURE_TAG, unitsOfMeasure) + .fieldIfNotNull(SELECTED_LABELS_TAG, selectedLabels) return builder.endObject() } + + internal data class Token( + val label: String, + ) : BaseModel { + internal companion object { + private const val LABEL_TAG = "label" + + /** + * reader to create instance of class from writable. + */ + val reader = Writeable.Reader { Token(it) } + + /** + * Parser to parse xContent + */ + val xParser = XParser { parse(it) } + + /** + * Parse the data from parser and create Trigger object + * @param parser data referenced at parser + * @return created Trigger object + */ + fun parse(parser: XContentParser): Token { + var label: String? = null + XContentParserUtils.ensureExpectedToken( + XContentParser.Token.START_OBJECT, + parser.currentToken(), + parser + ) + while (XContentParser.Token.END_OBJECT != parser.nextToken()) { + val fieldName = parser.currentName() + parser.nextToken() + when (fieldName) { + LABEL_TAG -> label = parser.text() + else -> log.info("$LOG_PREFIX: Trigger Skipping Unknown field $fieldName") + } + } + label ?: throw IllegalArgumentException("$LABEL_TAG field absent") + return Token(label) + } + } + + /** + * Constructor used in transport action communication. + * @param input StreamInput stream to deserialize data from. + */ + constructor(input: StreamInput) : this( + label = input.readString(), + ) + + /** + * {@inheritDoc} + */ + override fun writeTo(output: StreamOutput) { + output.writeString(label) + } + + /** + * {@inheritDoc} + */ + override fun toXContent(builder: XContentBuilder?, params: ToXContent.Params?): XContentBuilder { + builder!! + builder.startObject() + .field(LABEL_TAG, label) + builder.endObject() + return builder + } + } + + internal data class SelectedLabels( + val labels: List?, + ) : BaseModel { + internal companion object { + private const val LABELS_TAG = "labels" + + /** + * reader to create instance of class from writable. + */ + val reader = Writeable.Reader { SelectedLabels(it) } + + /** + * Parser to parse xContent + */ + val xParser = XParser { parse(it) } + + /** + * Parse the item list from parser + * @param parser data referenced at parser + * @return created list of items + */ + private fun parseItemList(parser: XContentParser): List { + val retList: MutableList = mutableListOf() + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_ARRAY, parser.currentToken(), parser) + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + retList.add(Token.parse(parser)) + } + return retList + } + + /** + * Parse the data from parser and create Trigger object + * @param parser data referenced at parser + * @return created Trigger object + */ + fun parse(parser: XContentParser): SelectedLabels { + var labels: List? = null + XContentParserUtils.ensureExpectedToken( + XContentParser.Token.START_ARRAY, + parser.currentToken(), + parser + ) + while (XContentParser.Token.END_ARRAY != parser.nextToken()) { + val fieldName = parser.currentName() + parser.nextToken() + when (fieldName) { + LABELS_TAG -> labels = parseItemList(parser) + else -> log.info("$LOG_PREFIX: Trigger Skipping Unknown field $fieldName") + } + } + labels ?: throw IllegalArgumentException("$LABELS_TAG field absent") + return SelectedLabels(labels) + } + } + + /** + * Constructor used in transport action communication. + * @param input StreamInput stream to deserialize data from. + */ + constructor(input: StreamInput) : this( + labels = input.readList(Token.reader) + ) + + /** + * {@inheritDoc} + */ + override fun writeTo(output: StreamOutput) { + output.writeCollection(labels) + } + + /** + * {@inheritDoc} + */ + override fun toXContent(builder: XContentBuilder?, params: ToXContent.Params?): XContentBuilder { + builder!! + builder.startObject() + if (labels != null) { + builder.startArray(LABELS_TAG) + labels.forEach { it.toXContent(builder, params) } + builder.endArray() + } + builder.endObject() + return builder + } + } } diff --git a/opensearch-observability/src/test/kotlin/org/opensearch/observability/model/SavedVisualizationTests.kt b/opensearch-observability/src/test/kotlin/org/opensearch/observability/model/SavedVisualizationTests.kt index 8129aad70..568ac15cd 100644 --- a/opensearch-observability/src/test/kotlin/org/opensearch/observability/model/SavedVisualizationTests.kt +++ b/opensearch-observability/src/test/kotlin/org/opensearch/observability/model/SavedVisualizationTests.kt @@ -30,7 +30,12 @@ internal class SavedVisualizationTests { listOf(SavedQuery.Token("utc_time", "timestamp")) ), "KE1Ie34BbsTr-CsB4G6Y", - "{\"dataConfig\":\"{}\",\"layoutConfig\":\"{}\"}" + "{\"dataConfig\":\"{}\",\"layoutConfig\":\"{}\"}", + "metric", + "hours (h)", + SavedVisualization.SelectedLabels( + listOf(SavedVisualization.Token("avg")) + ) ) @Test @@ -49,7 +54,7 @@ internal class SavedVisualizationTests { @Test fun `SavedVisualization should deserialize json object using parser`() { val jsonString = - "{\"name\":\"test-saved-visualization\",\"description\":\"test description\",\"query\":\"source=index | where utc_time > timestamp('2021-07-01 00:00:00') and utc_time < timestamp('2021-07-02 00:00:00')\",\"type\":\"bar\",\"selected_date_range\":{\"start\":\"now/15m\",\"end\":\"now\",\"text\":\"utc_time > timestamp('2021-07-01 00:00:00') and utc_time < timestamp('2021-07-02 00:00:00')\"},\"selected_timestamp\":{\"name\":\"utc_time\",\"type\":\"timestamp\"},\"selected_fields\":{\"text\":\"| fields clientip, bytes, memory, host\",\"tokens\":[{\"name\":\"utc_time\",\"type\":\"timestamp\"}]},\"application_id\":\"KE1Ie34BbsTr-CsB4G6Y\",\"user_configs\":\"{\\\"dataConfig\\\":\\\"{}\\\",\\\"layoutConfig\\\":\\\"{}\\\"}\"}" + "{\"name\":\"test-saved-visualization\",\"description\":\"test description\",\"query\":\"source=index | where utc_time > timestamp('2021-07-01 00:00:00') and utc_time < timestamp('2021-07-02 00:00:00')\",\"type\":\"bar\",\"selected_date_range\":{\"start\":\"now/15m\",\"end\":\"now\",\"text\":\"utc_time > timestamp('2021-07-01 00:00:00') and utc_time < timestamp('2021-07-02 00:00:00')\"},\"selected_timestamp\":{\"name\":\"utc_time\",\"type\":\"timestamp\"},\"selected_fields\":{\"text\":\"| fields clientip, bytes, memory, host\",\"tokens\":[{\"name\":\"utc_time\",\"type\":\"timestamp\"}]},\"application_id\":\"KE1Ie34BbsTr-CsB4G6Y\",\"user_configs\":\"{\\\"dataConfig\\\":\\\"{}\\\",\\\"layoutConfig\\\":\\\"{}\\\"}\",\"sub_type\":\"metric\",\"units_of_measure\":\"hours (h)\",\"labels\":[{\"label\":\"avg\"}]}" val recreatedObject = createObjectFromJsonString(jsonString) { SavedVisualization.parse(it) } assertEquals(sampleSavedVisualization, recreatedObject) } @@ -65,7 +70,7 @@ internal class SavedVisualizationTests { @Test fun `SavedVisualization should safely ignore extra field in json object`() { val jsonString = - "{\"name\":\"test-saved-visualization\",\"description\":\"test description\",\"query\":\"source=index | where utc_time > timestamp('2021-07-01 00:00:00') and utc_time < timestamp('2021-07-02 00:00:00')\",\"type\":\"bar\",\"selected_date_range\":{\"start\":\"now/15m\",\"end\":\"now\",\"text\":\"utc_time > timestamp('2021-07-01 00:00:00') and utc_time < timestamp('2021-07-02 00:00:00')\"},\"selected_timestamp\":{\"name\":\"utc_time\",\"type\":\"timestamp\"},\"selected_fields\":{\"text\":\"| fields clientip, bytes, memory, host\",\"tokens\":[{\"name\":\"utc_time\",\"type\":\"timestamp\"}]},\"application_id\":\"KE1Ie34BbsTr-CsB4G6Y\",\"user_configs\":\"{\\\"dataConfig\\\":\\\"{}\\\",\\\"layoutConfig\\\":\\\"{}\\\"}\"}" + "{\"name\":\"test-saved-visualization\",\"description\":\"test description\",\"query\":\"source=index | where utc_time > timestamp('2021-07-01 00:00:00') and utc_time < timestamp('2021-07-02 00:00:00')\",\"type\":\"bar\",\"selected_date_range\":{\"start\":\"now/15m\",\"end\":\"now\",\"text\":\"utc_time > timestamp('2021-07-01 00:00:00') and utc_time < timestamp('2021-07-02 00:00:00')\"},\"selected_timestamp\":{\"name\":\"utc_time\",\"type\":\"timestamp\"},\"selected_fields\":{\"text\":\"| fields clientip, bytes, memory, host\",\"tokens\":[{\"name\":\"utc_time\",\"type\":\"timestamp\"}]},\"application_id\":\"KE1Ie34BbsTr-CsB4G6Y\",\"user_configs\":\"{\\\"dataConfig\\\":\\\"{}\\\",\\\"layoutConfig\\\":\\\"{}\\\"}\",\"sub_type\":\"metric\",\"units_of_measure\":\"hours (h)\",\"labels\":[{\"label\":\"avg\"}]}" val recreatedObject = createObjectFromJsonString(jsonString) { SavedVisualization.parse(it) } assertEquals(sampleSavedVisualization, recreatedObject) }