@oneuptime/common 10.0.11 → 10.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/Models/AnalyticsModels/ExceptionInstance.ts +98 -0
  2. package/Models/DatabaseModels/TelemetryException.ts +105 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1772111896988-MigrationName.ts +41 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  5. package/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.ts +3 -15
  6. package/Server/Utils/Telemetry.ts +7 -2
  7. package/Server/Utils/VM/VMAPI.ts +204 -0
  8. package/Tests/Server/Utils/VM/VMAPI.test.ts +310 -0
  9. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js +87 -0
  10. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js.map +1 -1
  11. package/build/dist/Models/DatabaseModels/TelemetryException.js +108 -0
  12. package/build/dist/Models/DatabaseModels/TelemetryException.js.map +1 -1
  13. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1772111896988-MigrationName.js +20 -0
  14. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1772111896988-MigrationName.js.map +1 -0
  15. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  16. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  17. package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js +3 -9
  18. package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js.map +1 -1
  19. package/build/dist/Server/Utils/Telemetry.js +4 -2
  20. package/build/dist/Server/Utils/Telemetry.js.map +1 -1
  21. package/build/dist/Server/Utils/VM/VMAPI.js +153 -0
  22. package/build/dist/Server/Utils/VM/VMAPI.js.map +1 -1
  23. package/build/dist/Tests/Server/Utils/VM/VMAPI.test.js +214 -0
  24. package/build/dist/Tests/Server/Utils/VM/VMAPI.test.js.map +1 -0
  25. package/package.json +1 -1
@@ -311,6 +311,77 @@ export default class ExceptionInstance extends AnalyticsBaseModel {
311
311
  },
312
312
  });
313
313
 
314
+ const releaseColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
315
+ key: "release",
316
+ title: "Release",
317
+ description:
318
+ "Service version / release from service.version resource attribute",
319
+ required: false,
320
+ type: TableColumnType.Text,
321
+ accessControl: {
322
+ read: [
323
+ Permission.ProjectOwner,
324
+ Permission.ProjectAdmin,
325
+ Permission.ProjectMember,
326
+ Permission.ReadTelemetryException,
327
+ ],
328
+ create: [
329
+ Permission.ProjectOwner,
330
+ Permission.ProjectAdmin,
331
+ Permission.ProjectMember,
332
+ Permission.CreateTelemetryException,
333
+ ],
334
+ update: [],
335
+ },
336
+ });
337
+
338
+ const environmentColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
339
+ key: "environment",
340
+ title: "Environment",
341
+ description:
342
+ "Deployment environment from deployment.environment resource attribute",
343
+ required: false,
344
+ type: TableColumnType.Text,
345
+ accessControl: {
346
+ read: [
347
+ Permission.ProjectOwner,
348
+ Permission.ProjectAdmin,
349
+ Permission.ProjectMember,
350
+ Permission.ReadTelemetryException,
351
+ ],
352
+ create: [
353
+ Permission.ProjectOwner,
354
+ Permission.ProjectAdmin,
355
+ Permission.ProjectMember,
356
+ Permission.CreateTelemetryException,
357
+ ],
358
+ update: [],
359
+ },
360
+ });
361
+
362
+ const parsedFramesColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
363
+ key: "parsedFrames",
364
+ title: "Parsed Stack Frames",
365
+ description: "Stack trace parsed into structured frames (JSON array)",
366
+ required: false,
367
+ type: TableColumnType.Text,
368
+ accessControl: {
369
+ read: [
370
+ Permission.ProjectOwner,
371
+ Permission.ProjectAdmin,
372
+ Permission.ProjectMember,
373
+ Permission.ReadTelemetryException,
374
+ ],
375
+ create: [
376
+ Permission.ProjectOwner,
377
+ Permission.ProjectAdmin,
378
+ Permission.ProjectMember,
379
+ Permission.CreateTelemetryException,
380
+ ],
381
+ update: [],
382
+ },
383
+ });
384
+
314
385
  const attributesColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
315
386
  key: "attributes",
316
387
  title: "Attributes",
@@ -384,6 +455,9 @@ export default class ExceptionInstance extends AnalyticsBaseModel {
384
455
  spanIdColumn,
385
456
  fingerprintColumn,
386
457
  spanNameColumn,
458
+ releaseColumn,
459
+ environmentColumn,
460
+ parsedFramesColumn,
387
461
  attributesColumn,
388
462
  ],
389
463
  projections: [],
@@ -504,4 +578,28 @@ export default class ExceptionInstance extends AnalyticsBaseModel {
504
578
  public set spanName(v: string | undefined) {
505
579
  this.setColumnValue("spanName", v);
506
580
  }
581
+
582
+ public get release(): string | undefined {
583
+ return this.getColumnValue("release") as string | undefined;
584
+ }
585
+
586
+ public set release(v: string | undefined) {
587
+ this.setColumnValue("release", v);
588
+ }
589
+
590
+ public get environment(): string | undefined {
591
+ return this.getColumnValue("environment") as string | undefined;
592
+ }
593
+
594
+ public set environment(v: string | undefined) {
595
+ this.setColumnValue("environment", v);
596
+ }
597
+
598
+ public get parsedFrames(): string | undefined {
599
+ return this.getColumnValue("parsedFrames") as string | undefined;
600
+ }
601
+
602
+ public set parsedFrames(v: string | undefined) {
603
+ this.setColumnValue("parsedFrames", v);
604
+ }
507
605
  }
@@ -1026,4 +1026,109 @@ export default class TelemetryException extends DatabaseBaseModel {
1026
1026
  default: 1,
1027
1027
  })
1028
1028
  public occuranceCount?: number = undefined;
1029
+
1030
+ @ColumnAccessControl({
1031
+ create: [
1032
+ Permission.ProjectOwner,
1033
+ Permission.ProjectAdmin,
1034
+ Permission.CreateTelemetryException,
1035
+ ],
1036
+ read: [
1037
+ Permission.ProjectOwner,
1038
+ Permission.ProjectAdmin,
1039
+ Permission.ProjectMember,
1040
+ Permission.ReadTelemetryException,
1041
+ Permission.ReadAllProjectResources,
1042
+ ],
1043
+ update: [
1044
+ Permission.ProjectOwner,
1045
+ Permission.ProjectAdmin,
1046
+ Permission.EditTelemetryException,
1047
+ ],
1048
+ })
1049
+ @TableColumn({
1050
+ required: false,
1051
+ type: TableColumnType.LongText,
1052
+ canReadOnRelationQuery: true,
1053
+ title: "First Seen In Release",
1054
+ description:
1055
+ "The service version / release in which this exception was first observed",
1056
+ example: "v1.2.3",
1057
+ })
1058
+ @Column({
1059
+ nullable: true,
1060
+ type: ColumnType.LongText,
1061
+ length: ColumnLength.LongText,
1062
+ })
1063
+ public firstSeenInRelease?: string = undefined;
1064
+
1065
+ @ColumnAccessControl({
1066
+ create: [
1067
+ Permission.ProjectOwner,
1068
+ Permission.ProjectAdmin,
1069
+ Permission.CreateTelemetryException,
1070
+ ],
1071
+ read: [
1072
+ Permission.ProjectOwner,
1073
+ Permission.ProjectAdmin,
1074
+ Permission.ProjectMember,
1075
+ Permission.ReadTelemetryException,
1076
+ Permission.ReadAllProjectResources,
1077
+ ],
1078
+ update: [
1079
+ Permission.ProjectOwner,
1080
+ Permission.ProjectAdmin,
1081
+ Permission.EditTelemetryException,
1082
+ ],
1083
+ })
1084
+ @TableColumn({
1085
+ required: false,
1086
+ type: TableColumnType.LongText,
1087
+ canReadOnRelationQuery: true,
1088
+ title: "Last Seen In Release",
1089
+ description:
1090
+ "The most recent service version / release in which this exception was observed",
1091
+ example: "v1.4.0",
1092
+ })
1093
+ @Column({
1094
+ nullable: true,
1095
+ type: ColumnType.LongText,
1096
+ length: ColumnLength.LongText,
1097
+ })
1098
+ public lastSeenInRelease?: string = undefined;
1099
+
1100
+ @ColumnAccessControl({
1101
+ create: [
1102
+ Permission.ProjectOwner,
1103
+ Permission.ProjectAdmin,
1104
+ Permission.CreateTelemetryException,
1105
+ ],
1106
+ read: [
1107
+ Permission.ProjectOwner,
1108
+ Permission.ProjectAdmin,
1109
+ Permission.ProjectMember,
1110
+ Permission.ReadTelemetryException,
1111
+ Permission.ReadAllProjectResources,
1112
+ ],
1113
+ update: [
1114
+ Permission.ProjectOwner,
1115
+ Permission.ProjectAdmin,
1116
+ Permission.EditTelemetryException,
1117
+ ],
1118
+ })
1119
+ @TableColumn({
1120
+ required: false,
1121
+ type: TableColumnType.LongText,
1122
+ canReadOnRelationQuery: true,
1123
+ title: "Environment",
1124
+ description:
1125
+ "Deployment environment from deployment.environment resource attribute",
1126
+ example: "production",
1127
+ })
1128
+ @Column({
1129
+ nullable: true,
1130
+ type: ColumnType.LongText,
1131
+ length: ColumnLength.LongText,
1132
+ })
1133
+ public environment?: string = undefined;
1029
1134
  }
@@ -0,0 +1,41 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ export class MigrationName1772111896988 implements MigrationInterface {
4
+ public name = "MigrationName1772111896988";
5
+
6
+ public async up(queryRunner: QueryRunner): Promise<void> {
7
+ await queryRunner.query(
8
+ `ALTER TABLE "TelemetryException" ADD "firstSeenInRelease" character varying(500)`,
9
+ );
10
+ await queryRunner.query(
11
+ `ALTER TABLE "TelemetryException" ADD "lastSeenInRelease" character varying(500)`,
12
+ );
13
+ await queryRunner.query(
14
+ `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
15
+ );
16
+ await queryRunner.query(
17
+ `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
18
+ );
19
+ await queryRunner.query(
20
+ `ALTER TABLE "TelemetryException" ADD "environment" character varying(500)`,
21
+ );
22
+ }
23
+
24
+ public async down(queryRunner: QueryRunner): Promise<void> {
25
+ await queryRunner.query(
26
+ `ALTER TABLE "TelemetryException" DROP COLUMN "environment"`,
27
+ );
28
+ await queryRunner.query(
29
+ `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
30
+ );
31
+ await queryRunner.query(
32
+ `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
33
+ );
34
+ await queryRunner.query(
35
+ `ALTER TABLE "TelemetryException" DROP COLUMN "lastSeenInRelease"`,
36
+ );
37
+ await queryRunner.query(
38
+ `ALTER TABLE "TelemetryException" DROP COLUMN "firstSeenInRelease"`,
39
+ );
40
+ }
41
+ }
@@ -259,6 +259,7 @@ import { MigrationName1770732721195 } from "./1770732721195-MigrationName";
259
259
  import { MigrationName1770833704656 } from "./1770833704656-MigrationName";
260
260
  import { MigrationName1770834237090 } from "./1770834237090-MigrationName";
261
261
  import { MigrationName1770834237091 } from "./1770834237091-MigrationName";
262
+ import { MigrationName1772111896988 } from "./1772111896988-MigrationName";
262
263
 
263
264
  export default [
264
265
  InitialMigration,
@@ -522,4 +523,5 @@ export default [
522
523
  MigrationName1770833704656,
523
524
  MigrationName1770834237090,
524
525
  MigrationName1770834237091,
526
+ MigrationName1772111896988,
525
527
  ];
@@ -159,11 +159,7 @@ export default class IncomingRequestCriteria {
159
159
  }
160
160
  }
161
161
 
162
- if (
163
- input.criteriaFilter.checkOn === CheckOn.RequestBody &&
164
- !(input.dataToProcess as IncomingMonitorRequest)
165
- .onlyCheckForIncomingRequestReceivedAt
166
- ) {
162
+ if (input.criteriaFilter.checkOn === CheckOn.RequestBody) {
167
163
  let responseBody: string | JSONObject | undefined = (
168
164
  input.dataToProcess as IncomingMonitorRequest
169
165
  ).requestBody;
@@ -200,11 +196,7 @@ export default class IncomingRequestCriteria {
200
196
  }
201
197
  }
202
198
 
203
- if (
204
- input.criteriaFilter.checkOn === CheckOn.RequestHeader &&
205
- !(input.dataToProcess as IncomingMonitorRequest)
206
- .onlyCheckForIncomingRequestReceivedAt
207
- ) {
199
+ if (input.criteriaFilter.checkOn === CheckOn.RequestHeader) {
208
200
  const headerKeys: Array<string> = Object.keys(
209
201
  (input.dataToProcess as IncomingMonitorRequest).requestHeaders || {},
210
202
  ).map((key: string) => {
@@ -227,11 +219,7 @@ export default class IncomingRequestCriteria {
227
219
  }
228
220
  }
229
221
 
230
- if (
231
- input.criteriaFilter.checkOn === CheckOn.RequestHeaderValue &&
232
- !(input.dataToProcess as IncomingMonitorRequest)
233
- .onlyCheckForIncomingRequestReceivedAt
234
- ) {
222
+ if (input.criteriaFilter.checkOn === CheckOn.RequestHeaderValue) {
235
223
  const headerValues: Array<string> = Object.values(
236
224
  (input.dataToProcess as IncomingMonitorRequest).requestHeaders || {},
237
225
  ).map((key: string) => {
@@ -34,10 +34,13 @@ import {
34
34
  import type { PushMetricExporter } from "@opentelemetry/sdk-metrics/build/src/export/MetricExporter";
35
35
  import * as opentelemetry from "@opentelemetry/sdk-node";
36
36
  import { SpanExporter } from "@opentelemetry/sdk-trace-base";
37
- import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
37
+ import {
38
+ ATTR_SERVICE_NAME,
39
+ ATTR_SERVICE_VERSION,
40
+ } from "@opentelemetry/semantic-conventions";
38
41
  import URL from "../../Types/API/URL";
39
42
  import Dictionary from "../../Types/Dictionary";
40
- import { DisableTelemetry } from "../EnvironmentConfig";
43
+ import { AppVersion, Env, DisableTelemetry } from "../EnvironmentConfig";
41
44
  import logger from "./Logger";
42
45
 
43
46
  type ResourceWithRawAttributes = LogsResource & {
@@ -141,6 +144,8 @@ export default class Telemetry {
141
144
  public static getResource(data: { serviceName: string }): Resource {
142
145
  return new Resource({
143
146
  [ATTR_SERVICE_NAME]: data.serviceName,
147
+ [ATTR_SERVICE_VERSION]: AppVersion,
148
+ ["deployment.environment"]: Env,
144
149
  });
145
150
  }
146
151
 
@@ -69,6 +69,14 @@ export default class VMUtil {
69
69
  valueToReplaceInPlace.toString().includes("}}")
70
70
  ) {
71
71
  let valueToReplaceInPlaceCopy: string = valueToReplaceInPlace.toString();
72
+
73
+ // First, expand {{#each path}}...{{/each}} loops before variable substitution
74
+ valueToReplaceInPlaceCopy = VMUtil.expandEachLoops(
75
+ storageMap,
76
+ valueToReplaceInPlaceCopy,
77
+ isJSON,
78
+ );
79
+
72
80
  const variablesInArgument: Array<string> = [];
73
81
 
74
82
  const regex: RegExp = /{{(.*?)}}/g; // Find all matches of the regular expression and capture the word between the braces {{x}} => x
@@ -128,6 +136,202 @@ export default class VMUtil {
128
136
  return valueToReplaceInPlace;
129
137
  }
130
138
 
139
+ /**
140
+ * Expand {{#each path}}...{{/each}} loop blocks by iterating over arrays.
141
+ *
142
+ * Supports:
143
+ * - {{variableName}} inside the loop body resolves relative to the current array element
144
+ * - {{@index}} resolves to the 0-based index of the current iteration
145
+ * - {{this}} resolves to the current element value (useful for primitive arrays)
146
+ * - Nested {{#each}} blocks for multi-level array traversal
147
+ * - If the resolved path is not an array, the block is removed (replaced with empty string)
148
+ *
149
+ * Example:
150
+ * {{#each requestBody.alerts}}
151
+ * Alert {{@index}}: {{labels.label}} - {{status}}
152
+ * {{/each}}
153
+ */
154
+ @CaptureSpan()
155
+ public static expandEachLoops(
156
+ storageMap: JSONObject,
157
+ template: string,
158
+ isJSON: boolean | undefined,
159
+ ): string {
160
+ let result: string = template;
161
+ const maxIterations: number = 100; // safety limit to prevent infinite loops
162
+ let iterations: number = 0;
163
+
164
+ while (iterations < maxIterations) {
165
+ iterations++;
166
+
167
+ // Find the first (outermost) {{#each ...}} tag
168
+ const openTag: RegExp = /\{\{#each\s+(.*?)\}\}/;
169
+ const openMatch: RegExpExecArray | null = openTag.exec(result);
170
+
171
+ if (!openMatch) {
172
+ break; // no more {{#each}} blocks
173
+ }
174
+
175
+ const blockStart: number = openMatch.index!;
176
+ const arrayPath: string = openMatch[1]!.trim();
177
+ const bodyStart: number = blockStart + openMatch[0]!.length;
178
+
179
+ // Find the matching {{/each}} by counting nesting depth
180
+ let depth: number = 1;
181
+ let searchPos: number = bodyStart;
182
+ let matchEnd: number = -1;
183
+ let bodyEnd: number = -1;
184
+
185
+ while (depth > 0 && searchPos < result.length) {
186
+ const nextOpen: number = result.indexOf("{{#each ", searchPos);
187
+ const nextClose: number = result.indexOf("{{/each}}", searchPos);
188
+
189
+ if (nextClose === -1) {
190
+ // Unmatched {{#each}} — break out to avoid infinite loop
191
+ break;
192
+ }
193
+
194
+ if (nextOpen !== -1 && nextOpen < nextClose) {
195
+ // Found a nested {{#each}} before the next {{/each}}
196
+ depth++;
197
+ searchPos = nextOpen + 8; // skip past "{{#each "
198
+ } else {
199
+ // Found {{/each}}
200
+ depth--;
201
+ if (depth === 0) {
202
+ bodyEnd = nextClose;
203
+ matchEnd = nextClose + "{{/each}}".length;
204
+ }
205
+ searchPos = nextClose + "{{/each}}".length;
206
+ }
207
+ }
208
+
209
+ if (matchEnd === -1 || bodyEnd === -1) {
210
+ // Unmatched {{#each}} — remove it to prevent infinite loop
211
+ result =
212
+ result.slice(0, blockStart) +
213
+ result.slice(blockStart + openMatch[0]!.length);
214
+ continue;
215
+ }
216
+
217
+ const loopBody: string = result.slice(bodyStart, bodyEnd);
218
+
219
+ // Resolve the array from the storage map
220
+ const arrayValue: JSONValue = VMUtil.deepFind(storageMap, arrayPath);
221
+
222
+ if (!Array.isArray(arrayValue)) {
223
+ // Not an array — remove the block entirely
224
+ result = result.slice(0, blockStart) + result.slice(matchEnd);
225
+ continue;
226
+ }
227
+
228
+ // Expand the loop body for each element in the array
229
+ const expandedParts: Array<string> = [];
230
+
231
+ for (let i: number = 0; i < arrayValue.length; i++) {
232
+ const element: JSONValue = arrayValue[i]!;
233
+ let iterationBody: string = loopBody;
234
+
235
+ // Replace {{@index}} with the current index
236
+ iterationBody = iterationBody.replace(/\{\{@index\}\}/g, i.toString());
237
+
238
+ if (typeof element === "object" && element !== null) {
239
+ /*
240
+ * Merge element properties into a scoped storageMap so that:
241
+ * 1. Element properties can be accessed directly (e.g., {{status}})
242
+ * 2. Parent storageMap properties are still accessible (e.g., {{requestBody.receiver}})
243
+ */
244
+ const scopedStorageMap: JSONObject = {
245
+ ...storageMap,
246
+ ...(element as JSONObject),
247
+ };
248
+
249
+ // Recursively expand any nested {{#each}} blocks within the iteration body
250
+ iterationBody = VMUtil.expandEachLoops(
251
+ scopedStorageMap,
252
+ iterationBody,
253
+ isJSON,
254
+ );
255
+
256
+ // Replace remaining {{variable}} placeholders
257
+ iterationBody = VMUtil.replaceLoopVariables(
258
+ element as JSONObject,
259
+ storageMap,
260
+ iterationBody,
261
+ isJSON,
262
+ );
263
+ } else {
264
+ // For primitive array elements, replace {{this}} with the value
265
+ iterationBody = iterationBody.replace(
266
+ /\{\{this\}\}/g,
267
+ isJSON ? VMUtil.serializeValueForJSON(`${element}`) : `${element}`,
268
+ );
269
+ }
270
+
271
+ expandedParts.push(iterationBody);
272
+ }
273
+
274
+ result =
275
+ result.slice(0, blockStart) +
276
+ expandedParts.join("") +
277
+ result.slice(matchEnd);
278
+ }
279
+
280
+ return result;
281
+ }
282
+
283
+ /**
284
+ * Replace {{variable}} placeholders inside a loop body.
285
+ * Variables are resolved first against the current element (scoped),
286
+ * then fall back to the parent storageMap.
287
+ */
288
+ @CaptureSpan()
289
+ private static replaceLoopVariables(
290
+ element: JSONObject,
291
+ parentStorageMap: JSONObject,
292
+ body: string,
293
+ isJSON: boolean | undefined,
294
+ ): string {
295
+ const variableRegex: RegExp = /\{\{((?!#each\b|\/each\b|@index\b).*?)\}\}/g;
296
+ let match: RegExpExecArray | null = null;
297
+ const variables: Array<string> = [];
298
+
299
+ while ((match = variableRegex.exec(body)) !== null) {
300
+ if (match[1]) {
301
+ variables.push(match[1]);
302
+ }
303
+ }
304
+
305
+ for (const variable of variables) {
306
+ // First try resolving relative to the current element
307
+ let foundValue: JSONValue = VMUtil.deepFind(element, variable.trim());
308
+
309
+ // Fall back to the parent storage map (for absolute paths)
310
+ if (foundValue === undefined) {
311
+ foundValue = VMUtil.deepFind(parentStorageMap, variable.trim());
312
+ }
313
+
314
+ if (foundValue === undefined) {
315
+ continue; // leave unresolved
316
+ }
317
+
318
+ let replacement: string;
319
+
320
+ if (typeof foundValue === "object" && foundValue !== null) {
321
+ replacement = JSON.stringify(foundValue, null, 2);
322
+ } else {
323
+ replacement = `${foundValue}`;
324
+ }
325
+
326
+ body = body.replace(
327
+ "{{" + variable + "}}",
328
+ isJSON ? VMUtil.serializeValueForJSON(replacement) : replacement,
329
+ );
330
+ }
331
+
332
+ return body;
333
+ }
334
+
131
335
  @CaptureSpan()
132
336
  public static serializeValueForJSON(value: string): string {
133
337
  if (!value) {