@oneuptime/common 11.2.2 → 11.3.0

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 (26) hide show
  1. package/Models/AnalyticsModels/Span.ts +285 -0
  2. package/Server/Infrastructure/Queue.ts +20 -0
  3. package/Server/Utils/Monitor/MonitorResource.ts +19 -1
  4. package/Server/Utils/Telemetry/LlmSpan.ts +251 -0
  5. package/Server/Utils/Telemetry.ts +91 -0
  6. package/Tests/Server/Utils/Telemetry/LlmSpan.test.ts +164 -0
  7. package/Tests/Types/Currency.test.ts +44 -0
  8. package/Tests/Types/Monitor/MonitorType.test.ts +214 -0
  9. package/Types/Dashboard/DashboardComponents/DashboardTraceChartComponent.ts +8 -3
  10. package/UI/Components/Navbar/NavBar.tsx +13 -2
  11. package/Utils/Dashboard/Components/DashboardTraceChartComponent.ts +6 -58
  12. package/build/dist/Models/AnalyticsModels/Span.js +231 -0
  13. package/build/dist/Models/AnalyticsModels/Span.js.map +1 -1
  14. package/build/dist/Server/Infrastructure/Queue.js +7 -1
  15. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  16. package/build/dist/Server/Utils/Monitor/MonitorResource.js +17 -1
  17. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  18. package/build/dist/Server/Utils/Telemetry/LlmSpan.js +153 -0
  19. package/build/dist/Server/Utils/Telemetry/LlmSpan.js.map +1 -0
  20. package/build/dist/Server/Utils/Telemetry.js +69 -0
  21. package/build/dist/Server/Utils/Telemetry.js.map +1 -1
  22. package/build/dist/UI/Components/Navbar/NavBar.js +4 -1
  23. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  24. package/build/dist/Utils/Dashboard/Components/DashboardTraceChartComponent.js +6 -49
  25. package/build/dist/Utils/Dashboard/Components/DashboardTraceChartComponent.js.map +1 -1
  26. package/package.json +1 -1
@@ -524,17 +524,108 @@ export default class Telemetry {
524
524
  }): void {
525
525
  const { span, exception } = data;
526
526
 
527
+ const exceptionAttributes: Attributes =
528
+ this.getExceptionAttributes(exception);
529
+
527
530
  // log the exception as well
528
531
  logger.error(exception);
529
532
 
533
+ /*
534
+ * Span *events* (from recordException) are not reliably surfaced when the
535
+ * span is read back, and setStatus on its own only records the error CODE,
536
+ * not the message. So we also attach the exception details as queryable
537
+ * span attributes — including DB driver fields like the failing constraint
538
+ * and table — so the actual cause is visible in the trace UI instead of an
539
+ * empty "Error" status.
540
+ */
541
+ span.setAttributes(exceptionAttributes);
530
542
  span.recordException(exception as SpanException);
531
543
  span.setStatus({
532
544
  code: SpanStatusCode.ERROR,
545
+ message:
546
+ (exceptionAttributes["exception.message"] as string | undefined) ||
547
+ "Error",
533
548
  });
534
549
 
535
550
  this.endSpan(span);
536
551
  }
537
552
 
553
+ /*
554
+ * Pulls every useful field off an unknown thrown value into OpenTelemetry
555
+ * span attributes. Error message/stack are non-enumerable, so they are read
556
+ * explicitly. For database failures (TypeORM QueryFailedError / pg errors),
557
+ * the Postgres fields (SQLSTATE code, detail, constraint, table, column) live
558
+ * either on the error itself or on `driverError`; these are what tell us which
559
+ * constraint failed during e.g. a cascade delete.
560
+ */
561
+ private static getExceptionAttributes(exception: unknown): Attributes {
562
+ const attributes: Attributes = {};
563
+
564
+ if (exception === null || exception === undefined) {
565
+ attributes["exception.message"] =
566
+ "Unknown error: null or undefined was thrown";
567
+ return attributes;
568
+ }
569
+
570
+ if (exception instanceof Error) {
571
+ attributes["exception.type"] =
572
+ exception.name || exception.constructor?.name || "Error";
573
+ attributes["exception.message"] = exception.message || "";
574
+ if (exception.stack) {
575
+ attributes["exception.stacktrace"] = exception.stack.substring(0, 8000);
576
+ }
577
+ } else if (typeof exception === "string") {
578
+ attributes["exception.message"] = exception;
579
+ } else {
580
+ attributes["exception.message"] = this.safeStringify(exception);
581
+ }
582
+
583
+ type PotentialDatabaseError = {
584
+ code?: unknown;
585
+ detail?: unknown;
586
+ constraint?: unknown;
587
+ table?: unknown;
588
+ column?: unknown;
589
+ schema?: unknown;
590
+ query?: unknown;
591
+ driverError?: PotentialDatabaseError;
592
+ };
593
+
594
+ const error: PotentialDatabaseError = exception as PotentialDatabaseError;
595
+ const databaseError: PotentialDatabaseError = error.driverError || error;
596
+
597
+ const setStringAttribute: (key: string, value: unknown) => void = (
598
+ key: string,
599
+ value: unknown,
600
+ ): void => {
601
+ if (value !== undefined && value !== null && value !== "") {
602
+ attributes[key] = String(value);
603
+ }
604
+ };
605
+
606
+ // SQLSTATE (e.g. "23503" = foreign key violation) or a Node error code.
607
+ setStringAttribute("exception.code", error.code ?? databaseError.code);
608
+ setStringAttribute("db.error.detail", databaseError.detail);
609
+ setStringAttribute("db.error.constraint", databaseError.constraint);
610
+ setStringAttribute("db.error.table", databaseError.table);
611
+ setStringAttribute("db.error.column", databaseError.column);
612
+ setStringAttribute("db.error.schema", databaseError.schema);
613
+
614
+ if (typeof error.query === "string" && error.query.length > 0) {
615
+ attributes["db.statement"] = error.query.substring(0, 2000);
616
+ }
617
+
618
+ return attributes;
619
+ }
620
+
621
+ private static safeStringify(value: unknown): string {
622
+ try {
623
+ return JSON.stringify(value) || String(value);
624
+ } catch {
625
+ return String(value);
626
+ }
627
+ }
628
+
538
629
  public static endSpan(span: Span): void {
539
630
  span.end();
540
631
  }
@@ -0,0 +1,164 @@
1
+ import LlmSpanUtil, {
2
+ LlmSpanFields,
3
+ } from "../../../../Server/Utils/Telemetry/LlmSpan";
4
+ import { AttributeType } from "../../../../Server/Utils/Telemetry/Telemetry";
5
+ import Dictionary from "../../../../Types/Dictionary";
6
+ import { describe, expect, test } from "@jest/globals";
7
+
8
+ type Attrs = Dictionary<AttributeType | Array<AttributeType>>;
9
+
10
+ describe("LlmSpanUtil.extract", () => {
11
+ test("non-LLM span returns empty/default fields", () => {
12
+ const attrs: Attrs = {
13
+ "http.method": "GET",
14
+ "http.route": "/api/users",
15
+ "db.system": "postgresql",
16
+ };
17
+
18
+ const fields: LlmSpanFields = LlmSpanUtil.extract(attrs);
19
+
20
+ expect(fields.isLlmSpan).toBe(false);
21
+ expect(fields.llmSystem).toBe("");
22
+ expect(fields.llmTotalTokens).toBe(0);
23
+ expect(fields.llmCost).toBe(0);
24
+ });
25
+
26
+ test("empty attributes returns defaults", () => {
27
+ const fields: LlmSpanFields = LlmSpanUtil.extract({});
28
+ expect(fields).toEqual(LlmSpanUtil.empty());
29
+ });
30
+
31
+ test("OTel GenAI conventions (chat completion)", () => {
32
+ const attrs: Attrs = {
33
+ "gen_ai.system": "openai",
34
+ "gen_ai.operation.name": "chat",
35
+ "gen_ai.request.model": "gpt-4o",
36
+ "gen_ai.response.model": "gpt-4o-2024-08-06",
37
+ "gen_ai.usage.input_tokens": 1200,
38
+ "gen_ai.usage.output_tokens": 350,
39
+ "gen_ai.usage.cost": 0.0185,
40
+ };
41
+
42
+ const fields: LlmSpanFields = LlmSpanUtil.extract(attrs);
43
+
44
+ expect(fields.isLlmSpan).toBe(true);
45
+ expect(fields.llmSystem).toBe("openai");
46
+ expect(fields.llmOperation).toBe("chat");
47
+ expect(fields.llmRequestModel).toBe("gpt-4o");
48
+ expect(fields.llmResponseModel).toBe("gpt-4o-2024-08-06");
49
+ expect(fields.llmInputTokens).toBe(1200);
50
+ expect(fields.llmOutputTokens).toBe(350);
51
+ // total derived from input + output when not reported.
52
+ expect(fields.llmTotalTokens).toBe(1550);
53
+ expect(fields.llmCost).toBe(0.0185);
54
+ });
55
+
56
+ test("explicit total_tokens is preferred over derived sum", () => {
57
+ const attrs: Attrs = {
58
+ "gen_ai.system": "anthropic",
59
+ "gen_ai.request.model": "claude-opus-4-8",
60
+ "gen_ai.usage.input_tokens": 100,
61
+ "gen_ai.usage.output_tokens": 50,
62
+ "gen_ai.usage.total_tokens": 999,
63
+ };
64
+
65
+ const fields: LlmSpanFields = LlmSpanUtil.extract(attrs);
66
+ expect(fields.llmTotalTokens).toBe(999);
67
+ });
68
+
69
+ test("OpenLLMetry legacy prompt/completion token aliases", () => {
70
+ const attrs: Attrs = {
71
+ "gen_ai.system": "anthropic",
72
+ "gen_ai.request.model": "claude-sonnet-4-6",
73
+ "gen_ai.usage.prompt_tokens": 80,
74
+ "gen_ai.usage.completion_tokens": 20,
75
+ };
76
+
77
+ const fields: LlmSpanFields = LlmSpanUtil.extract(attrs);
78
+ expect(fields.isLlmSpan).toBe(true);
79
+ expect(fields.llmInputTokens).toBe(80);
80
+ expect(fields.llmOutputTokens).toBe(20);
81
+ expect(fields.llmTotalTokens).toBe(100);
82
+ });
83
+
84
+ test("OpenInference (llm.* + llm.token_count.*) conventions", () => {
85
+ const attrs: Attrs = {
86
+ "openinference.span.kind": "LLM",
87
+ "llm.system": "openai",
88
+ "llm.model_name": "gpt-4o-mini",
89
+ "llm.token_count.prompt": 500,
90
+ "llm.token_count.completion": 120,
91
+ "llm.token_count.total": 620,
92
+ };
93
+
94
+ const fields: LlmSpanFields = LlmSpanUtil.extract(attrs);
95
+ expect(fields.isLlmSpan).toBe(true);
96
+ expect(fields.llmSystem).toBe("openai");
97
+ expect(fields.llmOperation).toBe("LLM");
98
+ expect(fields.llmRequestModel).toBe("gpt-4o-mini");
99
+ expect(fields.llmInputTokens).toBe(500);
100
+ expect(fields.llmOutputTokens).toBe(120);
101
+ expect(fields.llmTotalTokens).toBe(620);
102
+ });
103
+
104
+ test("numeric token values reported as strings are coerced", () => {
105
+ const attrs: Attrs = {
106
+ "gen_ai.system": "openai",
107
+ "gen_ai.usage.input_tokens": "42",
108
+ "gen_ai.usage.output_tokens": "8",
109
+ };
110
+
111
+ const fields: LlmSpanFields = LlmSpanUtil.extract(attrs);
112
+ expect(fields.llmInputTokens).toBe(42);
113
+ expect(fields.llmOutputTokens).toBe(8);
114
+ expect(fields.llmTotalTokens).toBe(50);
115
+ });
116
+
117
+ test("agent + tool spans are detected", () => {
118
+ const agentAttrs: Attrs = {
119
+ "gen_ai.operation.name": "invoke_agent",
120
+ "gen_ai.agent.name": "research-agent",
121
+ };
122
+ const agentFields: LlmSpanFields = LlmSpanUtil.extract(agentAttrs);
123
+ expect(agentFields.isLlmSpan).toBe(true);
124
+ expect(agentFields.llmAgentName).toBe("research-agent");
125
+ expect(agentFields.llmOperation).toBe("invoke_agent");
126
+
127
+ const toolAttrs: Attrs = {
128
+ "gen_ai.operation.name": "execute_tool",
129
+ "gen_ai.tool.name": "web_search",
130
+ };
131
+ const toolFields: LlmSpanFields = LlmSpanUtil.extract(toolAttrs);
132
+ expect(toolFields.isLlmSpan).toBe(true);
133
+ expect(toolFields.llmToolName).toBe("web_search");
134
+ });
135
+
136
+ test("response model is used as request model fallback", () => {
137
+ const attrs: Attrs = {
138
+ "gen_ai.system": "openai",
139
+ "gen_ai.response.model": "gpt-4o-2024-08-06",
140
+ };
141
+ const fields: LlmSpanFields = LlmSpanUtil.extract(attrs);
142
+ expect(fields.llmRequestModel).toBe("gpt-4o-2024-08-06");
143
+ expect(fields.llmResponseModel).toBe("gpt-4o-2024-08-06");
144
+ });
145
+
146
+ test("bare gen_ai-namespaced attribute still flags as LLM span", () => {
147
+ const attrs: Attrs = {
148
+ "gen_ai.request.temperature": 0.7,
149
+ };
150
+ const fields: LlmSpanFields = LlmSpanUtil.extract(attrs);
151
+ expect(fields.isLlmSpan).toBe(true);
152
+ });
153
+
154
+ test("array-valued attributes do not break string/number extraction", () => {
155
+ const attrs: Attrs = {
156
+ "gen_ai.system": "openai",
157
+ "gen_ai.response.finish_reasons": ["stop"],
158
+ "gen_ai.usage.input_tokens": 10,
159
+ };
160
+ const fields: LlmSpanFields = LlmSpanUtil.extract(attrs);
161
+ expect(fields.isLlmSpan).toBe(true);
162
+ expect(fields.llmInputTokens).toBe(10);
163
+ });
164
+ });
@@ -0,0 +1,44 @@
1
+ import BadDataException from "../../Types/Exception/BadDataException";
2
+ import Currency from "../../Types/Currency";
3
+
4
+ describe("Currency", () => {
5
+ describe("convertToDecimalPlaces", () => {
6
+ test("rounds to two decimal places by default", () => {
7
+ expect(Currency.convertToDecimalPlaces(1.236)).toBe(1.24);
8
+ expect(Currency.convertToDecimalPlaces(1.231)).toBe(1.23);
9
+ expect(Currency.convertToDecimalPlaces(3.14159)).toBe(3.14);
10
+ expect(Currency.convertToDecimalPlaces(99.994)).toBe(99.99);
11
+ });
12
+
13
+ test("leaves values without fractional digits unchanged", () => {
14
+ expect(Currency.convertToDecimalPlaces(0)).toBe(0);
15
+ expect(Currency.convertToDecimalPlaces(100)).toBe(100);
16
+ });
17
+
18
+ test("rounds negative values", () => {
19
+ expect(Currency.convertToDecimalPlaces(-1.236)).toBe(-1.24);
20
+ });
21
+
22
+ test("honours a custom number of decimal places", () => {
23
+ expect(Currency.convertToDecimalPlaces(1.23456, 3)).toBe(1.235);
24
+ expect(Currency.convertToDecimalPlaces(1.26, 1)).toBe(1.3);
25
+ });
26
+
27
+ test("ceils the value when decimalPlaces is 0", () => {
28
+ expect(Currency.convertToDecimalPlaces(1.1, 0)).toBe(2);
29
+ expect(Currency.convertToDecimalPlaces(5, 0)).toBe(5);
30
+ expect(Currency.convertToDecimalPlaces(4.0001, 0)).toBe(5);
31
+ expect(Currency.convertToDecimalPlaces(-2.5, 0)).toBe(-2);
32
+ });
33
+
34
+ test("throws BadDataException when decimalPlaces is negative", () => {
35
+ expect(() => {
36
+ Currency.convertToDecimalPlaces(1.5, -1);
37
+ }).toThrowError(BadDataException);
38
+ });
39
+
40
+ test("returns a number", () => {
41
+ expect(typeof Currency.convertToDecimalPlaces(1.236)).toBe("number");
42
+ });
43
+ });
44
+ });
@@ -0,0 +1,214 @@
1
+ import BadDataException from "../../../Types/Exception/BadDataException";
2
+ import MonitorType, {
3
+ MonitorTypeCategory,
4
+ MonitorTypeHelper,
5
+ MonitorTypeProps,
6
+ } from "../../../Types/Monitor/MonitorType";
7
+
8
+ describe("MonitorTypeHelper", () => {
9
+ describe("getMonitorTypeCategories", () => {
10
+ test("returns a non-empty list of categories with labels and types", () => {
11
+ const categories: Array<MonitorTypeCategory> =
12
+ MonitorTypeHelper.getMonitorTypeCategories();
13
+
14
+ expect(Array.isArray(categories)).toBe(true);
15
+ expect(categories.length).toBeGreaterThan(0);
16
+
17
+ for (const category of categories) {
18
+ expect(typeof category.label).toBe("string");
19
+ expect(category.label.length).toBeGreaterThan(0);
20
+ expect(Array.isArray(category.monitorTypes)).toBe(true);
21
+ expect(category.monitorTypes.length).toBeGreaterThan(0);
22
+ }
23
+ });
24
+ });
25
+
26
+ describe("getAllMonitorTypeProps", () => {
27
+ test("returns props with a unique monitorType, title and description each", () => {
28
+ const props: Array<MonitorTypeProps> =
29
+ MonitorTypeHelper.getAllMonitorTypeProps();
30
+
31
+ expect(props.length).toBeGreaterThan(0);
32
+
33
+ const seen: Set<MonitorType> = new Set<MonitorType>();
34
+ for (const prop of props) {
35
+ expect(typeof prop.title).toBe("string");
36
+ expect(prop.title.length).toBeGreaterThan(0);
37
+ expect(typeof prop.description).toBe("string");
38
+ expect(prop.description.length).toBeGreaterThan(0);
39
+ expect(seen.has(prop.monitorType)).toBe(false);
40
+ seen.add(prop.monitorType);
41
+ }
42
+ });
43
+ });
44
+
45
+ describe("isTelemetryMonitor", () => {
46
+ test.each([
47
+ MonitorType.Logs,
48
+ MonitorType.Metrics,
49
+ MonitorType.Traces,
50
+ MonitorType.Exceptions,
51
+ MonitorType.Profiles,
52
+ MonitorType.Kubernetes,
53
+ MonitorType.Docker,
54
+ MonitorType.Host,
55
+ MonitorType.Podman,
56
+ MonitorType.DockerSwarm,
57
+ MonitorType.Proxmox,
58
+ MonitorType.Ceph,
59
+ ])("returns true for %s", (monitorType: MonitorType) => {
60
+ expect(MonitorTypeHelper.isTelemetryMonitor(monitorType)).toBe(true);
61
+ });
62
+
63
+ test.each([MonitorType.Manual, MonitorType.Website, MonitorType.API])(
64
+ "returns false for %s",
65
+ (monitorType: MonitorType) => {
66
+ expect(MonitorTypeHelper.isTelemetryMonitor(monitorType)).toBe(false);
67
+ },
68
+ );
69
+ });
70
+
71
+ describe("isManualMonitor", () => {
72
+ test("returns true only for Manual", () => {
73
+ expect(MonitorTypeHelper.isManualMonitor(MonitorType.Manual)).toBe(true);
74
+ expect(MonitorTypeHelper.isManualMonitor(MonitorType.Website)).toBe(
75
+ false,
76
+ );
77
+ expect(MonitorTypeHelper.isManualMonitor(MonitorType.API)).toBe(false);
78
+ });
79
+ });
80
+
81
+ describe("getTitle", () => {
82
+ test("returns the configured title", () => {
83
+ expect(MonitorTypeHelper.getTitle(MonitorType.API)).toBe("API");
84
+ expect(MonitorTypeHelper.getTitle(MonitorType.Docker)).toBe(
85
+ "Docker Container",
86
+ );
87
+ expect(MonitorTypeHelper.getTitle(MonitorType.Server)).toBe(
88
+ "Server / VM",
89
+ );
90
+ });
91
+
92
+ test("throws BadDataException for a type without props", () => {
93
+ expect(() => {
94
+ MonitorTypeHelper.getTitle("NonExistent" as MonitorType);
95
+ }).toThrowError(BadDataException);
96
+ });
97
+ });
98
+
99
+ describe("getDescription", () => {
100
+ test("returns a non-empty description for a known type", () => {
101
+ expect(
102
+ MonitorTypeHelper.getDescription(MonitorType.Ping).length,
103
+ ).toBeGreaterThan(0);
104
+ });
105
+
106
+ test("throws BadDataException for a type without props", () => {
107
+ expect(() => {
108
+ MonitorTypeHelper.getDescription("NonExistent" as MonitorType);
109
+ }).toThrowError(BadDataException);
110
+ });
111
+ });
112
+
113
+ describe("isProbableMonitor", () => {
114
+ test.each([
115
+ MonitorType.API,
116
+ MonitorType.Website,
117
+ MonitorType.IP,
118
+ MonitorType.Ping,
119
+ MonitorType.Port,
120
+ MonitorType.SSLCertificate,
121
+ MonitorType.SyntheticMonitor,
122
+ MonitorType.CustomJavaScriptCode,
123
+ MonitorType.SNMP,
124
+ MonitorType.DNS,
125
+ MonitorType.DNSSEC,
126
+ MonitorType.Domain,
127
+ MonitorType.ExternalStatusPage,
128
+ ])("returns true for %s", (monitorType: MonitorType) => {
129
+ expect(MonitorTypeHelper.isProbableMonitor(monitorType)).toBe(true);
130
+ });
131
+
132
+ test.each([
133
+ MonitorType.Manual,
134
+ MonitorType.Logs,
135
+ MonitorType.Server,
136
+ MonitorType.IncomingRequest,
137
+ ])("returns false for %s", (monitorType: MonitorType) => {
138
+ expect(MonitorTypeHelper.isProbableMonitor(monitorType)).toBe(false);
139
+ });
140
+ });
141
+
142
+ describe("doesMonitorTypeHaveInterval", () => {
143
+ test("mirrors isProbableMonitor", () => {
144
+ const types: Array<MonitorType> = [
145
+ MonitorType.API,
146
+ MonitorType.Manual,
147
+ MonitorType.Logs,
148
+ MonitorType.DNS,
149
+ ];
150
+
151
+ for (const monitorType of types) {
152
+ expect(MonitorTypeHelper.doesMonitorTypeHaveInterval(monitorType)).toBe(
153
+ MonitorTypeHelper.isProbableMonitor(monitorType),
154
+ );
155
+ }
156
+ });
157
+ });
158
+
159
+ describe("getActiveMonitorTypes", () => {
160
+ test("includes Server and excludes Manual", () => {
161
+ const active: Array<MonitorType> =
162
+ MonitorTypeHelper.getActiveMonitorTypes();
163
+
164
+ expect(active).toContain(MonitorType.Server);
165
+ expect(active).not.toContain(MonitorType.Manual);
166
+ });
167
+ });
168
+
169
+ describe("doesMonitorTypeHaveDocumentation", () => {
170
+ test.each([
171
+ MonitorType.IncomingRequest,
172
+ MonitorType.IncomingEmail,
173
+ MonitorType.Server,
174
+ ])("returns true for %s", (monitorType: MonitorType) => {
175
+ expect(
176
+ MonitorTypeHelper.doesMonitorTypeHaveDocumentation(monitorType),
177
+ ).toBe(true);
178
+ });
179
+
180
+ test("returns false for other types", () => {
181
+ expect(
182
+ MonitorTypeHelper.doesMonitorTypeHaveDocumentation(MonitorType.API),
183
+ ).toBe(false);
184
+ });
185
+ });
186
+
187
+ describe("doesMonitorTypeHaveCriteria", () => {
188
+ test("returns false only for Manual", () => {
189
+ expect(
190
+ MonitorTypeHelper.doesMonitorTypeHaveCriteria(MonitorType.Manual),
191
+ ).toBe(false);
192
+ expect(
193
+ MonitorTypeHelper.doesMonitorTypeHaveCriteria(MonitorType.API),
194
+ ).toBe(true);
195
+ });
196
+ });
197
+
198
+ describe("doesMonitorTypeHaveGraphs", () => {
199
+ test("returns true for graphable types and false otherwise", () => {
200
+ expect(
201
+ MonitorTypeHelper.doesMonitorTypeHaveGraphs(MonitorType.Website),
202
+ ).toBe(true);
203
+ expect(
204
+ MonitorTypeHelper.doesMonitorTypeHaveGraphs(MonitorType.Server),
205
+ ).toBe(true);
206
+ expect(
207
+ MonitorTypeHelper.doesMonitorTypeHaveGraphs(MonitorType.Manual),
208
+ ).toBe(false);
209
+ expect(
210
+ MonitorTypeHelper.doesMonitorTypeHaveGraphs(MonitorType.Logs),
211
+ ).toBe(false);
212
+ });
213
+ });
214
+ });
@@ -16,10 +16,15 @@ export default interface DashboardTraceChartComponent extends BaseComponent {
16
16
  // Substring filter on span name (e.g. "/Shipment/ShipShipment").
17
17
  spanNameContains?: string | undefined;
18
18
  /*
19
- * Attribute equality filters, ANDed "key=value; key2=value2"
20
- * (e.g. "url.host=torginol.starship.online").
19
+ * Attribute equality filters, ANDed. The structured editor stores a
20
+ * key/value record (e.g. { "url.host": "torginol.starship.online" }).
21
+ * A legacy "key=value; key2=value2" string is still read for widgets
22
+ * saved before the structured editor existed.
21
23
  */
22
- attributeFilters?: string | undefined;
24
+ attributeFilters?:
25
+ | string
26
+ | Record<string, string | number | boolean>
27
+ | undefined;
23
28
  /*
24
29
  * Optional split dimension: a span attribute key (e.g. url.host,
25
30
  * resource.service.instance.id) or a top-level column (name,
@@ -4,6 +4,7 @@ import React, {
4
4
  useState,
5
5
  useEffect,
6
6
  } from "react";
7
+ import { createPortal } from "react-dom";
7
8
  import Route from "../../../Types/API/Route";
8
9
  import URL from "../../../Types/API/URL";
9
10
  import IconProp from "../../../Types/Icon/IconProp";
@@ -368,9 +369,18 @@ const Navbar: FunctionComponent<ComponentProps> = (
368
369
  </div>
369
370
  )}
370
371
 
372
+ {/*
373
+ * Render the full-screen products modal in a portal on document.body so it
374
+ * is not a flex child of this `justify-between` nav. Otherwise it counts as
375
+ * a third flex item and shifts the right-side user/settings menu toward the
376
+ * middle whenever the modal opens. The modal is `fixed inset-0`, so its
377
+ * visual position is identical when portaled.
378
+ */}
371
379
  {isMoreMenuVisible &&
372
380
  props.moreMenuItems &&
373
- props.moreMenuItems.length > 0 && (
381
+ props.moreMenuItems.length > 0 &&
382
+ typeof document !== "undefined" &&
383
+ createPortal(
374
384
  <NavBarMenuModal
375
385
  items={props.moreMenuItems}
376
386
  footer={props.moreMenuFooter}
@@ -379,7 +389,8 @@ const Navbar: FunctionComponent<ComponentProps> = (
379
389
  keyboardHint={props.moreMenuKeyboardHint}
380
390
  recentLabel={props.moreMenuRecentLabel}
381
391
  onClose={closeMoreMenu}
382
- />
392
+ />,
393
+ document.body,
383
394
  )}
384
395
  </nav>
385
396
  );
@@ -15,11 +15,12 @@ const DisplaySection: ComponentArgumentSection = {
15
15
  order: 1,
16
16
  };
17
17
 
18
- const QuerySection: ComponentArgumentSection = {
19
- name: "Query",
20
- description: "Which spans to aggregate and how to split them",
21
- order: 2,
22
- };
18
+ /*
19
+ * The "Query" section (span-name filter, attribute filters, split-by, max
20
+ * series, include-child-spans) is rendered by a bespoke, structured editor —
21
+ * TraceChartQueryEditor — rather than these declarative free-text fields, so
22
+ * those arguments are deliberately not listed here. See ArgumentsForm.tsx.
23
+ */
23
24
 
24
25
  export default class DashboardTraceChartComponentUtil extends DashboardBaseComponentUtil {
25
26
  public static override getDefaultComponent(): DashboardTraceChartComponent {
@@ -76,59 +77,6 @@ export default class DashboardTraceChartComponentUtil extends DashboardBaseCompo
76
77
  ],
77
78
  });
78
79
 
79
- componentArguments.push({
80
- name: "Span Name Contains",
81
- description:
82
- "Only include spans whose name contains this text (e.g. /Shipment/ShipShipment)",
83
- required: false,
84
- type: ComponentInputType.Text,
85
- id: "spanNameContains",
86
- placeholder: "/Shipment/ShipShipment",
87
- section: QuerySection,
88
- });
89
-
90
- componentArguments.push({
91
- name: "Attribute Filters",
92
- description:
93
- "Attribute equality filters, ANDed — key=value pairs separated by semicolons (e.g. url.host=torginol.starship.online; http.method=POST)",
94
- required: false,
95
- type: ComponentInputType.Text,
96
- id: "attributeFilters",
97
- placeholder: "url.host=torginol.starship.online",
98
- section: QuerySection,
99
- });
100
-
101
- componentArguments.push({
102
- name: "Split By",
103
- description:
104
- "Optional dimension — a span attribute key (e.g. url.host, resource.service.instance.id) or one of: name, primaryEntityId, statusCode, kind. One series per value.",
105
- required: false,
106
- type: ComponentInputType.Text,
107
- id: "groupByAttribute",
108
- placeholder: "url.host",
109
- section: QuerySection,
110
- });
111
-
112
- componentArguments.push({
113
- name: "Top Series",
114
- description: "Cap on the number of series when split (default 10)",
115
- required: false,
116
- type: ComponentInputType.Number,
117
- id: "topLimit",
118
- placeholder: "10",
119
- section: QuerySection,
120
- });
121
-
122
- componentArguments.push({
123
- name: "Include Child Spans",
124
- description:
125
- "Count every span instead of root spans only (the traces explorer defaults to root spans)",
126
- required: false,
127
- type: ComponentInputType.Boolean,
128
- id: "includeChildSpans",
129
- section: QuerySection,
130
- });
131
-
132
80
  return componentArguments;
133
81
  }
134
82
  }